Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ toolchain go1.24.1

require (
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/chi/v5 v5.2.2
github.com/go-jose/go-jose/v4 v4.0.5
github.com/golang/mock v1.6.0
github.com/google/go-github/v31 v31.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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
Copy Markdown
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
Copy Markdown
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"`
}
4 changes: 2 additions & 2 deletions pkg/oidc/token_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,6 @@ type ClientCredentialsRequest struct {
Scope SpaceDelimitedArray `schema:"scope"`
ClientID string `schema:"client_id"`
ClientSecret string `schema:"client_secret"`
ClientAssertion string `schema:"client_assertion"`
ClientAssertionType string `schema:"client_assertion_type"`
ClientAssertion string `schema:"client_assertion,omitempty"`
ClientAssertionType string `schema:"client_assertion_type,omitempty"`
}
Loading