Prototype device authorization flow (core)

This commit is contained in:
Joe Kralicky 2024-05-16 16:47:02 -04:00
parent 229ef72e58
commit 56ce79e662
No known key found for this signature in database
GPG key ID: 75C4875F34A9FB79
13 changed files with 333 additions and 26 deletions

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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,
}
}

View file

@ -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")

View file

@ -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) {

View file

@ -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.

View file

@ -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)

View file

@ -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
}