mirror of
https://github.com/pomerium/pomerium.git
synced 2025-06-05 04:13:11 +02:00
Prototype device authorization flow (core)
This commit is contained in:
parent
229ef72e58
commit
56ce79e662
13 changed files with 333 additions and 26 deletions
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -95,6 +96,9 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
|
|||
// routes that don't need a session:
|
||||
sr.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut))
|
||||
sr.Path("/signed_out").Handler(httputil.HandlerFunc(a.signedOut)).Methods(http.MethodGet)
|
||||
sr.Path("/device_auth").Handler(httputil.HandlerFunc(a.DeviceAuthLogin)).
|
||||
Queries(urlutil.QueryDeviceAuthRouteURI, "").
|
||||
Methods(http.MethodGet, http.MethodPost)
|
||||
|
||||
// routes that need a session:
|
||||
sr = sr.NewRoute().Subrouter()
|
||||
|
@ -568,3 +572,102 @@ func (a *Authenticate) getIdentityProviderIDForRequest(r *http.Request) string {
|
|||
}
|
||||
return a.state.Load().flow.GetIdentityProviderIDForURLValues(r.Form)
|
||||
}
|
||||
|
||||
func (a *Authenticate) getRetryTokenForRequest(r *http.Request) []byte {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil
|
||||
}
|
||||
dec, _ := base64.URLEncoding.DecodeString(r.Form.Get(urlutil.QueryDeviceAuthRetryToken))
|
||||
return dec
|
||||
}
|
||||
|
||||
func (a *Authenticate) DeviceAuthLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
state := a.state.Load()
|
||||
options := a.options.Load()
|
||||
idpID := a.getIdentityProviderIDForRequest(r)
|
||||
|
||||
routeUri := r.FormValue(urlutil.QueryDeviceAuthRouteURI)
|
||||
ad := []byte(fmt.Sprintf("%s|%s|", routeUri, idpID))
|
||||
authenticator, err := a.cfg.getIdentityProvider(options, idpID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if the request includes a retry token
|
||||
if encRetryToken := a.getRetryTokenForRequest(r); len(encRetryToken) > 0 {
|
||||
retryTokenJwt, err := cryptutil.Decrypt(state.cookieCipher, []byte(encRetryToken), ad)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusUnauthorized, fmt.Errorf("bad retry token: %w", err))
|
||||
}
|
||||
var retryToken oidc.RetryToken
|
||||
if err := state.sharedEncoder.Unmarshal(retryTokenJwt, &retryToken); err != nil {
|
||||
return httputil.NewError(http.StatusUnauthorized, fmt.Errorf("bad retry token: %w", err))
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(time.Unix(0, retryToken.NotAfter)) {
|
||||
return httputil.NewError(http.StatusUnauthorized, fmt.Errorf("retry token expired"))
|
||||
} else if now.Before(time.Unix(0, retryToken.NotBefore)) {
|
||||
w.Header().Set("Retry-After", time.Until(time.Unix(0, retryToken.NotBefore)).String())
|
||||
return httputil.NewError(http.StatusTooManyRequests, fmt.Errorf("retry token not yet valid"))
|
||||
}
|
||||
|
||||
var claims identity.SessionClaims
|
||||
accessToken, err := authenticator.DeviceAccessToken(r.Context(), retryToken.AsDeviceAuthResponse(), &claims)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusInternalServerError, fmt.Errorf("failed to get device access token: %w", err))
|
||||
}
|
||||
|
||||
//
|
||||
// TODO: code copied from getOAuthCallback
|
||||
//
|
||||
s := sessions.NewState(idpID)
|
||||
err = claims.Claims.Claims(&s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshaling session state: %w", err)
|
||||
}
|
||||
|
||||
newState := s.WithNewIssuer(state.redirectURL.Hostname(), []string{state.redirectURL.Hostname()})
|
||||
|
||||
// save the session and access token to the databroker/cookie store
|
||||
if err := state.flow.PersistSession(r.Context(), w, &newState, claims, accessToken); err != nil {
|
||||
return fmt.Errorf("failed saving new session: %w", err)
|
||||
}
|
||||
|
||||
// ... and the user state to local storage.
|
||||
if err := state.sessionStore.SaveSession(w, r, &newState); err != nil {
|
||||
return fmt.Errorf("failed saving new session: %w", err)
|
||||
}
|
||||
//
|
||||
// end
|
||||
//
|
||||
|
||||
tokenJwt, err := state.sharedEncoder.Marshal(newState)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusInternalServerError, fmt.Errorf("failed to marshal session: %w", err))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"token": "%s"}`, string(tokenJwt))
|
||||
return nil
|
||||
} else {
|
||||
authResp, err := authenticator.DeviceAuth(w, r)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to get device code: %w", err))
|
||||
}
|
||||
// construct a retry token
|
||||
retryToken := oidc.NewRetryToken(authResp)
|
||||
// encode
|
||||
retryTokenJwt, err := state.sharedEncoder.Marshal(retryToken)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusInternalServerError,
|
||||
fmt.Errorf("failed to marshal retry token: %w", err))
|
||||
}
|
||||
|
||||
// write the user-facing part of the auth response plus the encrypted retry token
|
||||
userResp := oidc.NewUserDeviceAuthResponse(authResp, cryptutil.Encrypt(state.cookieCipher, retryTokenJwt, ad))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(userResp)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -342,6 +342,17 @@ func (s *Stateful) AuthenticateSignInURL(
|
|||
return redirectTo, nil
|
||||
}
|
||||
|
||||
func (s *Stateful) AuthenticateDeviceCode(w http.ResponseWriter, r *http.Request, params url.Values) error {
|
||||
deviceAuthURL := s.authenticateURL.ResolveReference(&url.URL{
|
||||
Path: "/.pomerium/device_auth",
|
||||
RawQuery: params.Encode(),
|
||||
})
|
||||
|
||||
signedURL := urlutil.NewSignedURL(s.sharedKey, deviceAuthURL)
|
||||
httputil.Redirect(w, r, signedURL.String(), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIdentityProviderIDForURLValues returns the identity provider ID
|
||||
// associated with the given URL values.
|
||||
func (s *Stateful) GetIdentityProviderIDForURLValues(vs url.Values) string {
|
||||
|
|
|
@ -379,6 +379,17 @@ func (s *Stateless) AuthenticateSignInURL(
|
|||
)
|
||||
}
|
||||
|
||||
func (s *Stateless) AuthenticateDeviceCode(w http.ResponseWriter, r *http.Request, params url.Values) error {
|
||||
signinURL := s.authenticateURL.ResolveReference(&url.URL{
|
||||
Path: "/.pomerium/device_auth",
|
||||
RawQuery: params.Encode(),
|
||||
})
|
||||
|
||||
signedURL := urlutil.NewSignedURL(s.sharedKey, signinURL)
|
||||
httputil.Redirect(w, r, signedURL.String(), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Callback handles a redirect to a route domain once signed in.
|
||||
func (s *Stateless) Callback(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
|
|
|
@ -22,6 +22,8 @@ const (
|
|||
QueryRequestUUID = "pomerium_request_uuid"
|
||||
QueryTraceparent = "pomerium_traceparent"
|
||||
QueryTracestate = "pomerium_tracestate"
|
||||
QueryDeviceAuthRetryToken = "pomerium_device_auth_retry_token"
|
||||
QueryDeviceAuthRouteURI = "pomerium_device_auth_route_uri"
|
||||
)
|
||||
|
||||
// URL signature based query params used for verifying the authenticity of a URL.
|
||||
|
|
|
@ -19,8 +19,14 @@ type MockProvider struct {
|
|||
UpdateUserInfoError error
|
||||
SignInError error
|
||||
SignOutError error
|
||||
DeviceAuthResponse oauth2.DeviceAuthResponse
|
||||
DeviceAuthError error
|
||||
DeviceAccessTokenResponse oauth2.Token
|
||||
DeviceAccessTokenError error
|
||||
}
|
||||
|
||||
var _ Authenticator = MockProvider{}
|
||||
|
||||
// Authenticate is a mocked providers function.
|
||||
func (mp MockProvider) Authenticate(context.Context, string, identity.State) (*oauth2.Token, error) {
|
||||
return &mp.AuthenticateResponse, mp.AuthenticateError
|
||||
|
@ -55,3 +61,13 @@ func (mp MockProvider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ s
|
|||
func (mp MockProvider) SignIn(_ http.ResponseWriter, _ *http.Request, _ string) error {
|
||||
return mp.SignInError
|
||||
}
|
||||
|
||||
// DeviceAccessToken implements Authenticator.
|
||||
func (mp MockProvider) DeviceAccessToken(ctx context.Context, r *oauth2.DeviceAuthResponse, state identity.State) (*oauth2.Token, error) {
|
||||
return &mp.DeviceAccessTokenResponse, mp.DeviceAccessTokenError
|
||||
}
|
||||
|
||||
// DeviceAuth implements Authenticator.
|
||||
func (mp MockProvider) DeviceAuth(w http.ResponseWriter, r *http.Request) (*oauth2.DeviceAuthResponse, error) {
|
||||
return &mp.DeviceAuthResponse, mp.DeviceAuthError
|
||||
}
|
||||
|
|
|
@ -182,3 +182,11 @@ func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string)
|
|||
func (p *Provider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ string) error {
|
||||
return oidc.ErrSignoutNotImplemented
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAuth(_ http.ResponseWriter, _ *http.Request) (*oauth2.DeviceAuthResponse, error) {
|
||||
return nil, oidc.ErrDeviceAuthNotImplemented
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAccessToken(_ context.Context, _ *oauth2.DeviceAuthResponse, _ identity.State) (*oauth2.Token, error) {
|
||||
return nil, oidc.ErrDeviceAuthNotImplemented
|
||||
}
|
||||
|
|
|
@ -256,3 +256,11 @@ func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string)
|
|||
func (p *Provider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ string) error {
|
||||
return oidc.ErrSignoutNotImplemented
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAuth(_ http.ResponseWriter, _ *http.Request) (*oauth2.DeviceAuthResponse, error) {
|
||||
return nil, oidc.ErrDeviceAuthNotImplemented
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAccessToken(_ context.Context, _ *oauth2.DeviceAuthResponse, _ identity.State) (*oauth2.Token, error) {
|
||||
return nil, oidc.ErrDeviceAuthNotImplemented
|
||||
}
|
||||
|
|
55
pkg/identity/oidc/device_auth.go
Normal file
55
pkg/identity/oidc/device_auth.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type UserDeviceAuthResponse struct {
|
||||
// UserCode is the code the user should enter at the verification uri
|
||||
UserCode string `json:"user_code"`
|
||||
// VerificationURI is where user should enter the user code
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
// VerificationURIComplete (if populated) includes the user code in the verification URI. This is typically shown to the user in non-textual form, such as a QR code.
|
||||
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
|
||||
|
||||
// InitialRetryDelay is the duration in seconds the client must wait before
|
||||
// attempting to retry the request, after completing their sign-in.
|
||||
// This gives the server time to poll the identity provider for the results.
|
||||
InitialRetryDelay int64 `json:"initial_retry_delay,omitempty"`
|
||||
|
||||
// RetryToken should be sent on subsequent retries of the original request.
|
||||
RetryToken []byte `json:"retry_token,omitempty"`
|
||||
}
|
||||
|
||||
type RetryToken struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
NotAfter int64 `json:"not_after"`
|
||||
}
|
||||
|
||||
func (rt RetryToken) AsDeviceAuthResponse() *oauth2.DeviceAuthResponse {
|
||||
return &oauth2.DeviceAuthResponse{
|
||||
DeviceCode: rt.DeviceCode,
|
||||
Expiry: time.Unix(0, rt.NotAfter),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRetryToken(authResp *oauth2.DeviceAuthResponse) RetryToken {
|
||||
return RetryToken{
|
||||
DeviceCode: authResp.DeviceCode,
|
||||
NotBefore: time.Now().Add(time.Duration(authResp.Interval) * time.Second).UnixNano(),
|
||||
NotAfter: authResp.Expiry.UnixNano(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewUserDeviceAuthResponse(authResp *oauth2.DeviceAuthResponse, retryTokenCiphertext []byte) UserDeviceAuthResponse {
|
||||
return UserDeviceAuthResponse{
|
||||
UserCode: authResp.UserCode,
|
||||
VerificationURI: authResp.VerificationURI,
|
||||
VerificationURIComplete: authResp.VerificationURIComplete,
|
||||
InitialRetryDelay: authResp.Interval,
|
||||
RetryToken: retryTokenCiphertext,
|
||||
}
|
||||
}
|
|
@ -13,6 +13,10 @@ var ErrRevokeNotImplemented = errors.New("identity/oidc: revoke not implemented"
|
|||
// https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated
|
||||
var ErrSignoutNotImplemented = errors.New("identity/oidc: end session not implemented")
|
||||
|
||||
// ErrDeviceAuthNotImplemented is returned when device auth is not implemented
|
||||
// by an identity provider.
|
||||
var ErrDeviceAuthNotImplemented = errors.New("identity/oidc: device auth not implemented")
|
||||
|
||||
// ErrMissingProviderURL is returned when an identity provider requires a provider url
|
||||
// does not receive one.
|
||||
var ErrMissingProviderURL = errors.New("identity/oidc: missing provider url")
|
||||
|
|
|
@ -123,6 +123,62 @@ func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAuth(w http.ResponseWriter, r *http.Request) (*oauth2.DeviceAuthResponse, error) {
|
||||
oa, err := p.GetOauthConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := defaultAuthCodeOptions
|
||||
for k, v := range p.AuthCodeOptions {
|
||||
opts = append(opts, oauth2.SetAuthURLParam(k, v))
|
||||
}
|
||||
|
||||
resp, err := oa.DeviceAuth(r.Context(), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *Provider) DeviceAccessToken(ctx context.Context, da *oauth2.DeviceAuthResponse, v identity.State) (*oauth2.Token, error) {
|
||||
oa, err := p.GetOauthConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oauth2Token, err := oa.DeviceAccessToken(ctx, da)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// TODO: the rest of this function is copied from Authenticate
|
||||
//
|
||||
|
||||
idToken, err := p.getIDToken(ctx, oauth2Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("identity/oidc: failed getting id_token: %w", err)
|
||||
}
|
||||
|
||||
if rawIDToken, ok := oauth2Token.Extra("id_token").(string); ok {
|
||||
v.SetRawIDToken(rawIDToken)
|
||||
}
|
||||
|
||||
// hydrate `v` using claims inside the returned `id_token`
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
|
||||
if err := idToken.Claims(v); err != nil {
|
||||
return nil, fmt.Errorf("identity/oidc: couldn't unmarshal extra claims %w", err)
|
||||
}
|
||||
|
||||
if err := p.UpdateUserInfo(ctx, oauth2Token, v); err != nil {
|
||||
return nil, fmt.Errorf("identity/oidc: couldn't update user info %w", err)
|
||||
}
|
||||
|
||||
return oauth2Token, nil
|
||||
}
|
||||
|
||||
// Authenticate converts an authorization code returned from the identity
|
||||
// provider into a token which is then converted into a user session.
|
||||
func (p *Provider) Authenticate(ctx context.Context, code string, v identity.State) (*oauth2.Token, error) {
|
||||
|
|
|
@ -39,6 +39,10 @@ type Authenticator interface {
|
|||
|
||||
SignIn(w http.ResponseWriter, r *http.Request, state string) error
|
||||
SignOut(w http.ResponseWriter, r *http.Request, idTokenHint, authenticateSignedOutURL, redirectToURL string) error
|
||||
|
||||
// alternatives for these methods?
|
||||
DeviceAuth(w http.ResponseWriter, r *http.Request) (*oauth2.DeviceAuthResponse, error)
|
||||
DeviceAccessToken(ctx context.Context, r *oauth2.DeviceAuthResponse, state State) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
// AuthenticatorConstructor makes an Authenticator from the given options.
|
||||
|
|
|
@ -68,6 +68,10 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) *
|
|||
return nil
|
||||
}))
|
||||
|
||||
a.Path("/v1/device_auth").Handler(httputil.HandlerFunc(p.DeviceAuthLogin)).
|
||||
Queries(urlutil.QueryDeviceAuthRouteURI, "").
|
||||
Methods(http.MethodGet, http.MethodPost)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -160,6 +164,30 @@ func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Proxy) DeviceAuthLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
state := p.state.Load()
|
||||
options := p.currentOptions.Load()
|
||||
|
||||
params := url.Values{}
|
||||
routeUri, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryDeviceAuthRouteURI))
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, err)
|
||||
}
|
||||
params.Set(urlutil.QueryDeviceAuthRouteURI, routeUri.String())
|
||||
|
||||
idp, err := options.GetIdentityProviderForRequestURL(routeUri.String())
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusInternalServerError, err)
|
||||
}
|
||||
params.Set(urlutil.QueryIdentityProviderID, idp.Id)
|
||||
|
||||
if retryToken := r.FormValue(urlutil.QueryDeviceAuthRetryToken); retryToken != "" {
|
||||
params.Set(urlutil.QueryDeviceAuthRetryToken, retryToken)
|
||||
}
|
||||
|
||||
return state.authenticateFlow.AuthenticateDeviceCode(w, r, params)
|
||||
}
|
||||
|
||||
// jwtAssertion returns the current request's JWT assertion (rfc7519#section-10.3.1).
|
||||
func (p *Proxy) jwtAssertion(w http.ResponseWriter, r *http.Request) error {
|
||||
rawAssertionJWT := r.Header.Get(httputil.HeaderPomeriumJWTAssertion)
|
||||
|
|
|
@ -18,6 +18,7 @@ var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)
|
|||
|
||||
type authenticateFlow interface {
|
||||
AuthenticateSignInURL(ctx context.Context, queryParams url.Values, redirectURL *url.URL, idpID string) (string, error)
|
||||
AuthenticateDeviceCode(w http.ResponseWriter, r *http.Request, queryParams url.Values) error
|
||||
Callback(w http.ResponseWriter, r *http.Request) error
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue