Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions example/server/exampleop/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ func newOP(storage op.Storage, issuer string, key [32]byte, logger *slog.Logger,
UserFormPath: "/device",
UserCode: op.UserCodeBase20,
},

PushedAuthorizationRequest: op.PushedAuthorizationRequestConfig{
Lifetime: time.Minute,
},
}
handler, err := op.NewOpenIDProvider(issuer, config, storage,
append([]op.Option{
Expand Down
49 changes: 37 additions & 12 deletions example/server/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,19 @@ var (
// typically you would implement this as a layer on top of your database
// for simplicity this example keeps everything in-memory
type Storage struct {
lock sync.Mutex
authRequests map[string]*AuthRequest
codes map[string]string
tokens map[string]*Token
clients map[string]*Client
userStore UserStore
services map[string]Service
refreshTokens map[string]*RefreshToken
signingKey signingKey
deviceCodes map[string]deviceAuthorizationEntry
userCodes map[string]string
serviceUsers map[string]*Client
lock sync.Mutex
authRequests map[string]*AuthRequest
codes map[string]string
tokens map[string]*Token
clients map[string]*Client
userStore UserStore
services map[string]Service
refreshTokens map[string]*RefreshToken
signingKey signingKey
deviceCodes map[string]deviceAuthorizationEntry
userCodes map[string]string
serviceUsers map[string]*Client
pushedAuthRequests map[string]*oidc.AuthRequest
}

type signingKey struct {
Expand Down Expand Up @@ -580,6 +581,30 @@ func (s *Storage) ValidateJWTProfileScopes(ctx context.Context, userID string, s
return allowedScopes, nil
}

// StorePAR implements the op.PARStorage interface.
func (s *Storage) StorePAR(ctx context.Context, requestURI string, request *oidc.AuthRequest, _ time.Time) error {
s.lock.Lock()
defer s.lock.Unlock()

s.pushedAuthRequests[requestURI] = request

return nil
}

// GetPARState returns the current state of the pushed authorization request flow in the database.
// Returned structure contains original auth request that should be used in auth API.
func (s *Storage) GetPARState(ctx context.Context, requestURI string) (*oidc.AuthRequest, error) {
s.lock.Lock()
defer s.lock.Unlock()

request, found := s.pushedAuthRequests[requestURI]
if !found {
return nil, errors.New("auth request wasn't found ")
}

return request, nil
}

// Health implements the op.Storage interface
func (s *Storage) Health(ctx context.Context) error {
return nil
Expand Down
21 changes: 21 additions & 0 deletions example/server/storage/storage_dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,27 @@ func (s *multiStorage) ValidateJWTProfileScopes(ctx context.Context, userID stri
return storage.ValidateJWTProfileScopes(ctx, userID, scopes)
}

// StorePAR implements the op.PARStorage interface.
func (s *multiStorage) StorePAR(ctx context.Context, requestURI string, request *oidc.AuthRequest, expiresAt time.Time) error {
storage, err := s.storageFromContext(ctx)
if err != nil {
return err
}

return storage.StorePAR(ctx, requestURI, request, expiresAt)
}

// GetPARState returns the current state of the pushed authorization request flow in the database.
// Returned structure contains original auth request that should be used in auth API.
func (s *multiStorage) GetPARState(ctx context.Context, requestURI string) (*oidc.AuthRequest, error) {
storage, err := s.storageFromContext(ctx)
if err != nil {
return nil, err
}

return storage.GetPARState(ctx, requestURI)
}

// Health implements the op.Storage interface
func (s *multiStorage) Health(ctx context.Context) error {
return nil
Expand Down
92 changes: 77 additions & 15 deletions pkg/client/rp/relying_party.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package rp
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
Expand Down Expand Up @@ -64,6 +67,10 @@ type RelyingParty interface {
// be used to start a DeviceAuthorization flow.
GetDeviceAuthorizationEndpoint() string

// GetPushedAuthorizationRequestEndpoint returns the endpoint which can
// be used to start a PAR flow (RFC-9126).
GetPushedAuthorizationRequestEndpoint() string

// IDTokenVerifier returns the verifier used for oidc id_token verification
IDTokenVerifier() *IDTokenVerifier

Expand Down Expand Up @@ -147,6 +154,10 @@ func (rp *relyingParty) GetDeviceAuthorizationEndpoint() string {
return rp.endpoints.DeviceAuthorizationURL
}

func (rp *relyingParty) GetPushedAuthorizationRequestEndpoint() string {
return rp.endpoints.PushedAuthorizationRequestEndpoint
}

func (rp *relyingParty) GetEndSessionEndpoint() string {
return rp.endpoints.EndSessionURL
}
Expand Down Expand Up @@ -391,12 +402,18 @@ func SignerFromKeyAndKeyID(key []byte, keyID string) SignerFromKey {

// AuthURL returns the auth request url
// (wrapping the oauth2 `AuthCodeURL`)
func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string {
func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) *url.URL {
authOpts := make([]oauth2.AuthCodeOption, 0)
for _, opt := range opts {
authOpts = append(authOpts, opt()...)
}
return rp.OAuthConfig().AuthCodeURL(state, authOpts...)

authURL, err := url.Parse(rp.OAuthConfig().AuthCodeURL(state, authOpts...))
if err != nil {
panic(err)
}

return authURL
}

// AuthURLHandler extends the `AuthURL` method with a http redirect handler
Expand All @@ -423,10 +440,53 @@ func AuthURLHandler(stateFn func() string, rp RelyingParty, urlParam ...URLParam
opts = append(opts, WithCodeChallenge(codeChallenge))
}

http.Redirect(w, r, AuthURL(state, rp, opts...), http.StatusFound)
authURL := AuthURL(state, rp, opts...)
if parURL := rp.GetPushedAuthorizationRequestEndpoint(); parURL != "" {
requestURI, err := pushedAuthorizationRequestAuthExtension(parURL, authURL.Query())
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(err.Error()))
return
}

authURL.RawQuery = url.Values{"request_uri": []string{requestURI}}.Encode()
}

http.Redirect(w, r, authURL.String(), http.StatusFound)
}
}

// pushedAuthorizationRequestAuthExtention implements first step of PAR (RFC-9126) extention,
// providing request_uri for auth redirect.
func pushedAuthorizationRequestAuthExtension(url string, query url.Values) (string, error) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I know that's a little bit dirty and can be done better

Open to discussion on this logic because it seems like therre is no good way to wrap existing x/oauth2 implementation

Looking forward for golang/go#65956 - once it'll be accepted to stdlib, we'll be able to get rid of a mess introduced in current PR

resp, err := http.PostForm(url, query)
if err != nil {
return "", fmt.Errorf("post form: %v", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %v", err)
}

if resp.StatusCode != http.StatusCreated {
// TODO: provide functionality for custom error handling per-code.
// See https://openid.net/specs/openid-connect-core-1_0.html#AuthError.
return "", errors.New(string(bodyBytes))
}

var parResp struct {
RequestURI string `json:"request_uri"`
}

err = json.Unmarshal(bodyBytes, &parResp)
if err != nil {
return "", fmt.Errorf("unmarshal body: %v", err)
}

return parResp.RequestURI, nil
}

// GenerateAndStoreCodeChallenge generates a PKCE code challenge and stores its verifier into a secure cookie
func GenerateAndStoreCodeChallenge(w http.ResponseWriter, rp RelyingParty) (string, error) {
codeVerifier := base64.RawURLEncoding.EncodeToString([]byte(uuid.New().String()))
Expand Down Expand Up @@ -632,12 +692,13 @@ type OptionFunc func(RelyingParty)

type Endpoints struct {
oauth2.Endpoint
IntrospectURL string
UserinfoURL string
JKWsURL string
EndSessionURL string
RevokeURL string
DeviceAuthorizationURL string
IntrospectURL string
UserinfoURL string
JKWsURL string
EndSessionURL string
RevokeURL string
DeviceAuthorizationURL string
PushedAuthorizationRequestEndpoint string
}

func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints {
Expand All @@ -646,12 +707,13 @@ func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints {
AuthURL: discoveryConfig.AuthorizationEndpoint,
TokenURL: discoveryConfig.TokenEndpoint,
},
IntrospectURL: discoveryConfig.IntrospectionEndpoint,
UserinfoURL: discoveryConfig.UserinfoEndpoint,
JKWsURL: discoveryConfig.JwksURI,
EndSessionURL: discoveryConfig.EndSessionEndpoint,
RevokeURL: discoveryConfig.RevocationEndpoint,
DeviceAuthorizationURL: discoveryConfig.DeviceAuthorizationEndpoint,
IntrospectURL: discoveryConfig.IntrospectionEndpoint,
UserinfoURL: discoveryConfig.UserinfoEndpoint,
JKWsURL: discoveryConfig.JwksURI,
EndSessionURL: discoveryConfig.EndSessionEndpoint,
RevokeURL: discoveryConfig.RevocationEndpoint,
DeviceAuthorizationURL: discoveryConfig.DeviceAuthorizationEndpoint,
PushedAuthorizationRequestEndpoint: discoveryConfig.PushedAuthorizationRequestEndpoint,
}
}

Expand Down
43 changes: 23 additions & 20 deletions pkg/oidc/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,31 @@ const (
// AuthRequest according to:
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
type AuthRequest struct {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding omitempty for each field below is required to minimize stored values size since it's being stored via JSON-serialization. Can be optimized even better in future by adding protobuf or msgpack serialization in this scenario.

Scopes SpaceDelimitedArray `json:"scope" schema:"scope"`
ResponseType ResponseType `json:"response_type" schema:"response_type"`
ClientID string `json:"client_id" schema:"client_id"`
RedirectURI string `json:"redirect_uri" schema:"redirect_uri"`

State string `json:"state" schema:"state"`
Nonce string `json:"nonce" schema:"nonce"`

ResponseMode ResponseMode `json:"response_mode" schema:"response_mode"`
Display Display `json:"display" schema:"display"`
Prompt SpaceDelimitedArray `json:"prompt" schema:"prompt"`
MaxAge *uint `json:"max_age" schema:"max_age"`
UILocales Locales `json:"ui_locales" schema:"ui_locales"`
IDTokenHint string `json:"id_token_hint" schema:"id_token_hint"`
LoginHint string `json:"login_hint" schema:"login_hint"`
ACRValues SpaceDelimitedArray `json:"acr_values" schema:"acr_values"`

CodeChallenge string `json:"code_challenge" schema:"code_challenge"`
CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method" schema:"code_challenge_method"`
Scopes SpaceDelimitedArray `json:"scope,omitempty" schema:"scope"`
ResponseType ResponseType `json:"response_type,omitempty" schema:"response_type"`
ClientID string `json:"client_id,omitempty" schema:"client_id"`
RedirectURI string `json:"redirect_uri,omitempty" schema:"redirect_uri"`

State string `json:"state,omitempty" schema:"state"`
Nonce string `json:"nonce,omitempty" schema:"nonce"`

ResponseMode ResponseMode `json:"response_mode,omitempty" schema:"response_mode"`
Display Display `json:"display,omitempty" schema:"display"`
Prompt SpaceDelimitedArray `json:"prompt,omitempty" schema:"prompt"`
MaxAge *uint `json:"max_age,omitempty" schema:"max_age"`
UILocales Locales `json:"ui_locales,omitempty" schema:"ui_locales"`
IDTokenHint string `json:"id_token_hint,omitempty" schema:"id_token_hint"`
LoginHint string `json:"login_hint,omitempty" schema:"login_hint"`
ACRValues SpaceDelimitedArray `json:"acr_values,omitempty" schema:"acr_values"`

CodeChallenge string `json:"code_challenge,omitempty" schema:"code_challenge"`
CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method,omitempty" schema:"code_challenge_method"`

// RequestParam enables OIDC requests to be passed in a single, self-contained parameter (as JWT, called Request Object)
RequestParam string `schema:"request"`
RequestParam string `schema:"request,omitempty"`

// RequestParam enables OIDC requests to be passed via intermediate cache (RFC-9126)
RequestURI string `schema:"request_uri,omitempty"`
}

func (a *AuthRequest) LogValue() slog.Value {
Expand Down
2 changes: 2 additions & 0 deletions pkg/oidc/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type DiscoveryConfiguration struct {

DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`

PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"`

// CheckSessionIframe is a URL where the OP provides an iframe that support cross-origin communications for session state information with the RP Client.
CheckSessionIframe string `json:"check_session_iframe,omitempty"`

Expand Down
21 changes: 21 additions & 0 deletions pkg/oidc/pushed_authorization_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package oidc

// PARRequest implements

// https://datatracker.ietf.org/doc/html/rfc9126#name-request,

// 2.1 Request.

type PARRequest AuthRequest

// PARResponse implements

// https://www.rfc-editor.org/rfc/rfc8628#section-3.2

// 3.2. Successful Response.

type PARResponse struct {
RequestURI string `json:"request_uri"`

ExpiresIn int `json:"expires_in"`
}
Loading