diff --git a/authentication/oauth.go b/authentication/oauth.go index 7e4f06c2..1d4ffec1 100644 --- a/authentication/oauth.go +++ b/authentication/oauth.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strings" "time" "github.com/google/uuid" @@ -207,6 +208,53 @@ func (o *OAuth) RevokeRefreshToken(ctx context.Context, body oauth.RevokeRefresh return o.authentication.Request(ctx, "POST", o.authentication.URI("oauth", "revoke"), body, nil, opts...) } +// PushedAuthorization performs a Pushed Authorization Request that can be used to initiate an OAuth flow from +// the backchannel instead of building a URL. +// +// See: https://www.rfc-editor.org/rfc/rfc9126.html +func (o *OAuth) PushedAuthorization(ctx context.Context, body oauth.PushedAuthorizationRequest, opts ...RequestOption) (p *oauth.PushedAuthorizationRequestResponse, err error) { + missing := []string{} + check(&missing, "ClientID", (body.ClientID != "" || o.authentication.clientID != "")) + check(&missing, "ResponseType", body.ResponseType != "") + check(&missing, "RedirectURI", body.RedirectURI != "") + + if len(missing) > 0 { + return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", ")) + } + + data := url.Values{ + "response_type": []string{body.ResponseType}, + "redirect_uri": []string{body.RedirectURI}, + } + + addIfNotEmpty("scope", body.Scope, data) + addIfNotEmpty("audience", body.Audience, data) + addIfNotEmpty("nonce", body.Nonce, data) + addIfNotEmpty("response_mode", body.ResponseMode, data) + addIfNotEmpty("organization", body.Organization, data) + addIfNotEmpty("invitation", body.Invitation, data) + addIfNotEmpty("connection", body.Connection, data) + addIfNotEmpty("code_challenge", body.CodeChallenge, data) + + for key, value := range body.ExtraParameters { + data.Set(key, value) + } + + err = o.addClientAuthentication(body.ClientAuthentication, data, true) + + if err != nil { + return nil, err + } + + err = o.authentication.Request(ctx, "POST", o.authentication.URI("oauth", "par"), data, &p, opts...) + + if err != nil { + return nil, err + } + + return +} + func (o *OAuth) addClientAuthentication(params oauth.ClientAuthentication, body url.Values, required bool) error { clientID := params.ClientID if params.ClientID == "" { @@ -289,3 +337,15 @@ func createClientAssertion(clientAssertionSigningAlg, clientAssertionSigningKey, return string(b), nil } + +func addIfNotEmpty(key string, value string, qs url.Values) { + if value != "" { + qs.Set(key, value) + } +} + +func check(errors *[]string, key string, c bool) { + if !c { + *errors = append(*errors, key) + } +} diff --git a/authentication/oauth/oauth.go b/authentication/oauth/oauth.go index 35d123b4..f58c0c7f 100644 --- a/authentication/oauth/oauth.go +++ b/authentication/oauth/oauth.go @@ -114,3 +114,36 @@ type IDTokenValidationOptions struct { Nonce string Organization string } + +// PushedAuthorizationRequest defines the request body for performing a Pushed Authorization Request (PAR). +type PushedAuthorizationRequest struct { + ClientAuthentication + // The URI to redirect to. + RedirectURI string + // Scopes to request. + Scope string + // The unique identifier of the target API you want to access. + Audience string + // The nonce. + Nonce string + // The response mode to use. + ResponseMode string + // The response type the client expects. + ResponseType string + // The organization to log the user in to. + Organization string + // The ID of an invitation to accept. + Invitation string + // Name of the connection. + Connection string + // A Base64-encoded SHA-256 hash of the code_verifier used for the Authorization Code Flow with PKCE. + CodeChallenge string + // Extra parameters to be added to the request. Values set here will override any existing values. + ExtraParameters map[string]string +} + +// PushedAuthorizationRequestResponse defines the response from a Pushed Authorization Request. +type PushedAuthorizationRequestResponse struct { + RequestURI string `json:"request_uri,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` +} diff --git a/authentication/oauth_test.go b/authentication/oauth_test.go index be9104c8..26120d3e 100644 --- a/authentication/oauth_test.go +++ b/authentication/oauth_test.go @@ -408,6 +408,71 @@ func TestOAuthWithIDTokenVerification(t *testing.T) { }) } +func TestPushedAuthorizationRequest(t *testing.T) { + t.Run("Should require a client secret", func(t *testing.T) { + _, err := authAPI.OAuth.PushedAuthorization(context.Background(), oauth.PushedAuthorizationRequest{ + ResponseType: "code", + RedirectURI: "http://localhost:3000/callback", + }) + assert.ErrorContains(t, err, "client_secret or client_assertion is required but not provided") + }) + + t.Run("Should require a ClientID, ResponseType and RedirectURI", func(t *testing.T) { + auth, err := New( + context.Background(), + domain, + ) + require.NoError(t, err) + _, err = auth.OAuth.PushedAuthorization(context.Background(), oauth.PushedAuthorizationRequest{}) + assert.ErrorContains(t, err, "Missing required fields: ClientID, ResponseType, RedirectURI") + }) + + t.Run("Should make a PAR request", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + res, err := authAPI.OAuth.PushedAuthorization(context.Background(), oauth.PushedAuthorizationRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + ResponseType: "code", + RedirectURI: "http://localhost:3000/callback", + }) + + require.NoError(t, err) + assert.NotEmpty(t, res.RequestURI) + assert.NotEmpty(t, res.ExpiresIn) + }) + + t.Run("Should support all arguments", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + res, err := authAPI.OAuth.PushedAuthorization(context.Background(), oauth.PushedAuthorizationRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + ResponseType: "code", + RedirectURI: "http://localhost:3000/callback", + Audience: "test-audience", + Nonce: "abc123", + ResponseMode: "form_post", + Scope: "openid profile email", + Organization: "my-org", + Invitation: "invite", + Connection: "Username-Password", + CodeChallenge: "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", + ExtraParameters: map[string]string{ + "test": "value", + }, + }) + + require.NoError(t, err) + assert.NotEmpty(t, res.RequestURI) + assert.NotEmpty(t, res.ExpiresIn) + }) +} + func withIDToken(t *testing.T, extras map[string]interface{}) (*Authentication, error) { t.Helper() diff --git a/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_make_a_PAR_request.yaml b/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_make_a_PAR_request.yaml new file mode 100644 index 00000000..fe68cd58 --- /dev/null +++ b/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_make_a_PAR_request.yaml @@ -0,0 +1,44 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 194 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + redirect_uri: + - http://localhost:3000/callback + response_type: + - code + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/par + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: 132 + uncompressed: false + body: '{"expires_in":30,"request_uri":"urn:ietf:params:oauth:request_uri:test-value"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: 201 Created + code: 201 + duration: 669.090416ms diff --git a/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_support_all_arguments.yaml b/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_support_all_arguments.yaml new file mode 100644 index 00000000..a6220834 --- /dev/null +++ b/test/data/recordings/authentication/TestPushedAuthorizationRequest/Should_support_all_arguments.yaml @@ -0,0 +1,62 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 395 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: audience=test-audience&client_id=test-client_id&client_secret=test-client_secret&code_challenge=n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg&connection=Username-Password&invitation=invite&nonce=abc123&organization=my-org&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_mode=form_post&response_type=code&scope=openid+profile+email&test=value + form: + audience: + - test-audience + client_id: + - test-client_id + client_secret: + - test-client_secret + code_challenge: + - n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg + connection: + - Username-Password + invitation: + - invite + nonce: + - abc123 + organization: + - my-org + redirect_uri: + - http://localhost:3000/callback + response_mode: + - form_post + response_type: + - code + scope: + - openid profile email + test: + - value + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/par + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: 132 + uncompressed: false + body: '{"expires_in":30,"request_uri":"urn:ietf:params:oauth:request_uri:test-value"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: 201 Created + code: 201 + duration: 397.494667ms