diff --git a/examples/oauth.ts b/examples/fapi2.ts index cc6d632..80ec0f4 100644 --- a/examples/oauth.ts +++ b/examples/fapi2.ts @@ -8,12 +8,22 @@ let algorithm!: | 'oidc' /* For .well-known/openid-configuration discovery */ | undefined /* Defaults to 'oidc' */ let client_id!: string -let client_secret!: string /** * Value used in the authorization request as redirect_uri pre-registered at the Authorization * Server. */ let redirect_uri!: string +/** + * In order to take full advantage of DPoP you shall generate a random private key for every + * session. In the browser environment you shall use IndexedDB to persist the generated + * CryptoKeyPair. + */ +let DPoP!: CryptoKeyPair +/** + * A key that the client has pre-registered at the Authorization Server for use with Private Key JWT + * client authentication method. + */ +let clientPrivateKey!: CryptoKey // End of prerequisites @@ -23,38 +33,68 @@ const as = await oauth const client: oauth.Client = { client_id, - client_secret, - token_endpoint_auth_method: 'client_secret_basic', + token_endpoint_auth_method: 'private_key_jwt', } const code_challenge_method = 'S256' /** * The following MUST be generated for every redirect to the authorization_endpoint. You must store - * the code_verifier and nonce in the end-user session such that it can be recovered as the user - * gets redirected from the authorization server back to your application. + * the code_verifier in the end-user session such that it can be recovered as the user gets + * redirected from the authorization server back to your application. */ const code_verifier = oauth.generateRandomCodeVerifier() const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier) -let state: string | undefined + +// Pushed Authorization Request & Response (PAR) +let request_uri: string +{ + const params = new URLSearchParams() + params.set('client_id', client.client_id) + params.set('code_challenge', code_challenge) + params.set('code_challenge_method', code_challenge_method) + params.set('redirect_uri', redirect_uri) + params.set('response_type', 'code') + params.set('scope', 'api:read') + + const pushedAuthorizationRequest = () => + oauth.pushedAuthorizationRequest(as, client, params, { + DPoP, + clientPrivateKey, + }) + let response = await pushedAuthorizationRequest() + let challenges: oauth.WWWAuthenticateChallenge[] | undefined + if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) { + for (const challenge of challenges) { + console.error('WWW-Authenticate Challenge', challenge) + } + throw new Error() // Handle WWW-Authenticate Challenges as needed + } + + const processPushedAuthorizationResponse = () => + oauth.processPushedAuthorizationResponse(as, client, response) + let result = await processPushedAuthorizationResponse() + if (oauth.isOAuth2Error(result)) { + console.error('Error Response', result) + if (result.error === 'use_dpop_nonce') { + // the AS-signalled nonce is now cached, retrying + response = await pushedAuthorizationRequest() + result = await processPushedAuthorizationResponse() + if (oauth.isOAuth2Error(result)) { + throw new Error() // Handle OAuth 2.0 response body error + } + } else { + throw new Error() // Handle OAuth 2.0 response body error + } + } + + ;({ request_uri } = result) +} { // redirect user to as.authorization_endpoint const authorizationUrl = new URL(as.authorization_endpoint!) authorizationUrl.searchParams.set('client_id', client.client_id) - authorizationUrl.searchParams.set('redirect_uri', redirect_uri) - authorizationUrl.searchParams.set('response_type', 'code') - authorizationUrl.searchParams.set('scope', 'api:read') - authorizationUrl.searchParams.set('code_challenge', code_challenge) - authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) - - /** - * We cannot be sure the AS supports PKCE so we're going to use state too. Use of PKCE is - * backwards compatible even if the AS doesn't support it which is why we're using it regardless. - */ - if (as.code_challenge_methods_supported?.includes('S256') !== true) { - state = oauth.generateRandomState() - authorizationUrl.searchParams.set('state', state) - } + authorizationUrl.searchParams.set('request_uri', request_uri) // now redirect the user to authorizationUrl.href } @@ -65,19 +105,16 @@ let access_token: string { // @ts-expect-error const currentUrl: URL = getCurrentUrl() - const params = oauth.validateAuthResponse(as, client, currentUrl, state) + const params = oauth.validateAuthResponse(as, client, currentUrl) if (oauth.isOAuth2Error(params)) { console.error('Error Response', params) throw new Error() // Handle OAuth 2.0 redirect error } - const response = await oauth.authorizationCodeGrantRequest( - as, - client, - params, - redirect_uri, - code_verifier, - ) + const authorizationCodeGrantRequest = () => + oauth.authorizationCodeGrantRequest(as, client, params, redirect_uri, code_verifier, { DPoP }) + + let response = await authorizationCodeGrantRequest() let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) { @@ -87,10 +124,22 @@ let access_token: string throw new Error() // Handle WWW-Authenticate Challenges as needed } - const result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response) + const processAuthorizationCodeOAuth2Response = () => + oauth.processAuthorizationCodeOAuth2Response(as, client, response) + + let result = await processAuthorizationCodeOAuth2Response() if (oauth.isOAuth2Error(result)) { console.error('Error Response', result) - throw new Error() // Handle OAuth 2.0 response body error + if (result.error === 'use_dpop_nonce') { + // the AS-signalled nonce is now cached, retrying + response = await authorizationCodeGrantRequest() + result = await processAuthorizationCodeOAuth2Response() + if (oauth.isOAuth2Error(result)) { + throw new Error() // Handle OAuth 2.0 response body error + } + } else { + throw new Error() // Handle OAuth 2.0 response body error + } } console.log('Access Token Response', result) @@ -99,18 +148,33 @@ let access_token: string // Protected Resource Request { - const response = await oauth.protectedResourceRequest( - access_token, - 'GET', - new URL('https://rs.example.com/api'), - ) + const protectedResourceRequest = () => + oauth.protectedResourceRequest( + access_token, + 'GET', + new URL('https://rs.example.com/api'), + undefined, + undefined, + { DPoP }, + ) + let response = await protectedResourceRequest() let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) { - for (const challenge of challenges) { - console.error('WWW-Authenticate Challenge', challenge) + const { 0: challenge, length } = challenges + if ( + length === 1 && + challenge.scheme === 'dpop' && + challenge.parameters.error === 'use_dpop_nonce' + ) { + // the AS-signalled nonce is now cached, retrying + response = await protectedResourceRequest() + } else { + for (const challenge of challenges) { + console.error('WWW-Authenticate Challenge', challenge) + } + throw new Error() // Handle WWW-Authenticate Challenges as needed } - throw new Error() // Handle WWW-Authenticate Challenges as needed } console.log('Protected Resource Response', await response.json())