Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK-4738] Add support for performing Pushed Authorization Requests #327

Merged
merged 3 commits into from
Dec 11, 2023
Merged
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
60 changes: 60 additions & 0 deletions authentication/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
}
}
33 changes: 33 additions & 0 deletions authentication/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
65 changes: 65 additions & 0 deletions authentication/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +438 to +439
Copy link
Contributor

@Widcket Widcket Dec 7, 2023

Choose a reason for hiding this comment

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

Is there any way we can mark these as required?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll add something to the function body to check for the required fields

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added in 65a3389

})

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading