From 6c38d476aec968d64a4b98e4f1f019416e7a4b0f Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 2 Aug 2023 07:44:05 +0200 Subject: [PATCH 1/7] Refactor grant classes to take an options object ...instead of an OAuth2Class instance that only wraps that config --- mod.ts | 16 +++++----- src/authorization_code_grant.ts | 36 +++++++++++----------- src/client_credentials_grant.ts | 12 ++++---- src/grant_base.ts | 6 ++-- src/implicit_grant.ts | 22 ++++++------- src/oauth2_client.ts | 18 +++++++---- src/refresh_token_grant.ts | 12 ++++---- src/resource_owner_password_credentials.ts | 16 +++++----- 8 files changed, 72 insertions(+), 66 deletions(-) diff --git a/mod.ts b/mod.ts index 84ee1e6..ee198cf 100644 --- a/mod.ts +++ b/mod.ts @@ -10,28 +10,28 @@ export { export { OAuth2Client } from "./src/oauth2_client.ts"; export type { OAuth2ClientConfig } from "./src/oauth2_client.ts"; +export { AuthorizationCodeGrant } from "./src/authorization_code_grant.ts"; export type { - AuthorizationCodeGrant, AuthorizationCodeTokenOptions, AuthorizationUri, AuthorizationUriOptions, AuthorizationUriWithoutVerifier, AuthorizationUriWithVerifier, } from "./src/authorization_code_grant.ts"; +export { ClientCredentialsGrant } from "./src/client_credentials_grant.ts"; export type { - ClientCredentialsGrant, ClientCredentialsTokenOptions, } from "./src/client_credentials_grant.ts"; +export { ImplicitGrant } from "./src/implicit_grant.ts"; export type { - ImplicitGrant, ImplicitTokenOptions, ImplicitUriOptions, } from "./src/implicit_grant.ts"; -export type { +export { ResourceOwnerPasswordCredentialsGrant, - ResourceOwnerPasswordCredentialsTokenOptions, } from "./src/resource_owner_password_credentials.ts"; export type { - RefreshTokenGrant, - RefreshTokenOptions, -} from "./src/refresh_token_grant.ts"; + ResourceOwnerPasswordCredentialsTokenOptions, +} from "./src/resource_owner_password_credentials.ts"; +export { RefreshTokenGrant } from "./src/refresh_token_grant.ts"; +export type { RefreshTokenOptions } from "./src/refresh_token_grant.ts"; diff --git a/src/authorization_code_grant.ts b/src/authorization_code_grant.ts index 699a23f..0a84526 100644 --- a/src/authorization_code_grant.ts +++ b/src/authorization_code_grant.ts @@ -1,4 +1,4 @@ -import type { OAuth2Client } from "./oauth2_client.ts"; +import type { OAuth2ClientConfig } from "./oauth2_client.ts"; import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts"; import type { RequestOptions, Tokens } from "./types.ts"; import { OAuth2GrantBase } from "./grant_base.ts"; @@ -72,8 +72,8 @@ export interface AuthorizationCodeTokenOptions { * See https://tools.ietf.org/html/rfc6749#section-4.1 */ export class AuthorizationCodeGrant extends OAuth2GrantBase { - constructor(client: OAuth2Client) { - super(client); + constructor(config: OAuth2ClientConfig) { + super(config); } /** @@ -98,11 +98,11 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { ): Promise { const params = new URLSearchParams(); params.set("response_type", "code"); - params.set("client_id", this.client.config.clientId); - if (typeof this.client.config.redirectUri === "string") { - params.set("redirect_uri", this.client.config.redirectUri); + params.set("client_id", this.config.clientId); + if (typeof this.config.redirectUri === "string") { + params.set("redirect_uri", this.config.redirectUri); } - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.config.defaults?.scope; if (scope) { params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); } @@ -112,7 +112,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { if (options.disablePkce === true) { return { - uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri), + uri: new URL(`?${params}`, this.config.authorizationEndpointUri), }; } @@ -120,7 +120,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { params.set("code_challenge", challenge.codeChallenge); params.set("code_challenge_method", challenge.codeChallengeMethod); return { - uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri), + uri: new URL(`?${params}`, this.config.authorizationEndpointUri), codeVerifier: challenge.codeVerifier, }; } @@ -156,8 +156,8 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { url: URL, options: AuthorizationCodeTokenOptions, ): Promise<{ code: string; state?: string }> { - if (typeof this.client.config.redirectUri === "string") { - const expectedUrl = new URL(this.client.config.redirectUri); + if (typeof this.config.redirectUri === "string") { + const expectedUrl = new URL(this.config.redirectUri); if ( typeof url.pathname === "string" && @@ -191,7 +191,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { const state = params.get("state"); const stateValidator = options.stateValidator || (options.state && ((s) => s === options.state)) || - this.client.config.defaults?.stateValidator; + this.config.defaults?.stateValidator; if (stateValidator && !await stateValidator(state)) { if (state === null) { @@ -226,20 +226,20 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { body.code_verifier = codeVerifier; } - if (typeof this.client.config.redirectUri === "string") { - body.redirect_uri = this.client.config.redirectUri; + if (typeof this.config.redirectUri === "string") { + body.redirect_uri = this.config.redirectUri; } - if (typeof this.client.config.clientSecret === "string") { + if (typeof this.config.clientSecret === "string") { // We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1. - const { clientId, clientSecret } = this.client.config; + const { clientId, clientSecret } = this.config; headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; } else { // This appears to be a public client, include the client ID along in the body - body.client_id = this.client.config.clientId; + body.client_id = this.config.clientId; } - return this.buildRequest(this.client.config.tokenUri, { + return this.buildRequest(this.config.tokenUri, { method: "POST", headers, body, diff --git a/src/client_credentials_grant.ts b/src/client_credentials_grant.ts index 3f4de01..17fe872 100644 --- a/src/client_credentials_grant.ts +++ b/src/client_credentials_grant.ts @@ -1,6 +1,6 @@ import { MissingClientSecretError } from "./errors.ts"; import { OAuth2GrantBase } from "./grant_base.ts"; -import type { OAuth2Client } from "./oauth2_client.ts"; +import type { OAuth2ClientConfig } from "./oauth2_client.ts"; import { RequestOptions, Tokens } from "./types.ts"; export interface ClientCredentialsTokenOptions { @@ -22,8 +22,8 @@ export interface ClientCredentialsTokenOptions { * See https://tools.ietf.org/html/rfc6749#section-4.4 */ export class ClientCredentialsGrant extends OAuth2GrantBase { - constructor(client: OAuth2Client) { - super(client); + constructor(config: OAuth2ClientConfig) { + super(config); } /** @@ -42,7 +42,7 @@ export class ClientCredentialsGrant extends OAuth2GrantBase { private buildTokenRequest( options: ClientCredentialsTokenOptions, ): Request { - const { clientId, clientSecret } = this.client.config; + const { clientId, clientSecret } = this.config; if (typeof clientSecret !== "string") { throw new MissingClientSecretError(); } @@ -56,7 +56,7 @@ export class ClientCredentialsGrant extends OAuth2GrantBase { "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, }; - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.config.defaults?.scope; if (scope) { if (Array.isArray(scope)) { body.scope = scope.join(" "); @@ -65,7 +65,7 @@ export class ClientCredentialsGrant extends OAuth2GrantBase { } } - return this.buildRequest(this.client.config.tokenUri, { + return this.buildRequest(this.config.tokenUri, { method: "POST", headers, body, diff --git a/src/grant_base.ts b/src/grant_base.ts index 5c4fb74..7f80551 100644 --- a/src/grant_base.ts +++ b/src/grant_base.ts @@ -1,5 +1,5 @@ import { OAuth2ResponseError, TokenResponseError } from "./errors.ts"; -import { OAuth2Client } from "./oauth2_client.ts"; +import { OAuth2ClientConfig } from "./oauth2_client.ts"; import { RequestOptions, Tokens } from "./types.ts"; interface AccessTokenResponse { @@ -17,7 +17,7 @@ interface AccessTokenResponse { */ export abstract class OAuth2GrantBase { constructor( - protected readonly client: OAuth2Client, + protected readonly config: OAuth2ClientConfig, ) {} protected buildRequest( @@ -27,7 +27,7 @@ export abstract class OAuth2GrantBase { ): Request { const url = this.toUrl(baseUrl); - const clientDefaults = this.client.config.defaults?.requestOptions; + const clientDefaults = this.config.defaults?.requestOptions; const urlParams: Record = { ...(clientDefaults?.urlParams), diff --git a/src/implicit_grant.ts b/src/implicit_grant.ts index a5e064e..008a771 100644 --- a/src/implicit_grant.ts +++ b/src/implicit_grant.ts @@ -1,4 +1,4 @@ -import type { OAuth2Client } from "./oauth2_client.ts"; +import type { OAuth2ClientConfig } from "./oauth2_client.ts"; import { OAuth2GrantBase } from "./grant_base.ts"; import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts"; import type { RequestOptions, Tokens } from "./types.ts"; @@ -40,26 +40,26 @@ export interface ImplicitTokenOptions { } export class ImplicitGrant extends OAuth2GrantBase { - constructor(client: OAuth2Client) { - super(client); + constructor(config: OAuth2ClientConfig) { + super(config); } /** Builds a URI you can redirect a user to to make the authorization request. */ public getAuthorizationUri(options: ImplicitUriOptions = {}): URL { const params = new URLSearchParams(); params.set("response_type", "token"); - params.set("client_id", this.client.config.clientId); - if (typeof this.client.config.redirectUri === "string") { - params.set("redirect_uri", this.client.config.redirectUri); + params.set("client_id", this.config.clientId); + if (typeof this.config.redirectUri === "string") { + params.set("redirect_uri", this.config.redirectUri); } - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.config.defaults?.scope; if (scope) { params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); } if (options.state) { params.set("state", options.state); } - return new URL(`?${params}`, this.client.config.authorizationEndpointUri); + return new URL(`?${params}`, this.config.authorizationEndpointUri); } /** @@ -77,8 +77,8 @@ export class ImplicitGrant extends OAuth2GrantBase { ? authResponseUri : new URL(authResponseUri); - if (typeof this.client.config.redirectUri === "string") { - const expectedUrl = new URL(this.client.config.redirectUri); + if (typeof this.config.redirectUri === "string") { + const expectedUrl = new URL(this.config.redirectUri); if ( typeof url.pathname === "string" && @@ -115,7 +115,7 @@ export class ImplicitGrant extends OAuth2GrantBase { const state = params.get("state"); const stateValidator = options.stateValidator || (options.state && ((s) => s === options.state)) || - this.client.config.defaults?.stateValidator; + this.config.defaults?.stateValidator; const tokens: Tokens = { accessToken, diff --git a/src/oauth2_client.ts b/src/oauth2_client.ts index 0471ba7..3248fac 100644 --- a/src/oauth2_client.ts +++ b/src/oauth2_client.ts @@ -38,37 +38,43 @@ export class OAuth2Client { * * See RFC6749, section 4.1. */ - public code = new AuthorizationCodeGrant(this); + public code: AuthorizationCodeGrant; /** * Implements the Implicit Grant. * * See RFC6749, section 4.2. */ - public implicit = new ImplicitGrant(this); + public implicit: ImplicitGrant; /** * Implements the Resource Owner Password Credentials Grant. * * See RFC6749, section 4.3. */ - public ropc = new ResourceOwnerPasswordCredentialsGrant(this); + public ropc: ResourceOwnerPasswordCredentialsGrant; /** * Implements the Resource Owner Password Credentials Grant. * * See RFC6749, section 4.4. */ - public clientCredentials = new ClientCredentialsGrant(this); + public clientCredentials: ClientCredentialsGrant; /** * Implements the Refresh Token Grant. * * See RFC6749, section 6. */ - public refreshToken = new RefreshTokenGrant(this); + public refreshToken: RefreshTokenGrant; constructor( public readonly config: Readonly, - ) {} + ) { + this.code = new AuthorizationCodeGrant(this.config); + this.implicit = new ImplicitGrant(this.config); + this.ropc = new ResourceOwnerPasswordCredentialsGrant(this.config); + this.clientCredentials = new ClientCredentialsGrant(this.config); + this.refreshToken = new RefreshTokenGrant(this.config); + } } diff --git a/src/refresh_token_grant.ts b/src/refresh_token_grant.ts index 98ec861..b3db17f 100644 --- a/src/refresh_token_grant.ts +++ b/src/refresh_token_grant.ts @@ -1,5 +1,5 @@ import { RequestOptions, Tokens } from "./types.ts"; -import { OAuth2Client } from "./oauth2_client.ts"; +import { OAuth2ClientConfig } from "./oauth2_client.ts"; import { OAuth2GrantBase } from "./grant_base.ts"; export interface RefreshTokenOptions { @@ -13,8 +13,8 @@ export interface RefreshTokenOptions { * See https://tools.ietf.org/html/rfc6749#section-6 */ export class RefreshTokenGrant extends OAuth2GrantBase { - constructor(client: OAuth2Client) { - super(client); + constructor(config: OAuth2ClientConfig) { + super(config); } /** Request new tokens from the authorization server using the given refresh token. */ @@ -36,15 +36,15 @@ export class RefreshTokenGrant extends OAuth2GrantBase { const headers: Record = { "Accept": "application/json", }; - if (typeof this.client.config.clientSecret === "string") { + if (typeof this.config.clientSecret === "string") { // Note: RFC 6749 doesn't really say how a non-confidential client should // prove its identity when making a refresh token request, so we just don't // do anything and let the user deal with that (e.g. using the requestOptions) - const { clientId, clientSecret } = this.client.config; + const { clientId, clientSecret } = this.config; headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; } - const req = this.buildRequest(this.client.config.tokenUri, { + const req = this.buildRequest(this.config.tokenUri, { method: "POST", body, headers, diff --git a/src/resource_owner_password_credentials.ts b/src/resource_owner_password_credentials.ts index acf08c5..a5afa44 100644 --- a/src/resource_owner_password_credentials.ts +++ b/src/resource_owner_password_credentials.ts @@ -1,5 +1,5 @@ import { OAuth2GrantBase } from "./grant_base.ts"; -import type { OAuth2Client } from "./oauth2_client.ts"; +import type { OAuth2ClientConfig } from "./oauth2_client.ts"; import type { RequestOptions, Tokens } from "./types.ts"; export interface ResourceOwnerPasswordCredentialsTokenOptions { @@ -24,8 +24,8 @@ export interface ResourceOwnerPasswordCredentialsTokenOptions { * See https://tools.ietf.org/html/rfc6749#section-4.3 */ export class ResourceOwnerPasswordCredentialsGrant extends OAuth2GrantBase { - constructor(client: OAuth2Client) { - super(client); + constructor(config: OAuth2ClientConfig) { + super(config); } /** @@ -53,7 +53,7 @@ export class ResourceOwnerPasswordCredentialsGrant extends OAuth2GrantBase { "Accept": "application/json", }; - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.config.defaults?.scope; if (scope) { if (Array.isArray(scope)) { body.scope = scope.join(" "); @@ -62,16 +62,16 @@ export class ResourceOwnerPasswordCredentialsGrant extends OAuth2GrantBase { } } - if (typeof this.client.config.clientSecret === "string") { + if (typeof this.config.clientSecret === "string") { // We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1. - const { clientId, clientSecret } = this.client.config; + const { clientId, clientSecret } = this.config; headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; } else { // This appears to be a public client, include the client ID in the body instead - body.client_id = this.client.config.clientId; + body.client_id = this.config.clientId; } - return this.buildRequest(this.client.config.tokenUri, { + return this.buildRequest(this.config.tokenUri, { method: "POST", headers, body, From 5a67938e7618f1103ef334c9314b908bc27854ee Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 2 Aug 2023 11:44:27 +0200 Subject: [PATCH 2/7] Allow extending OAuth 2.0 grant classes --- mod.ts | 2 ++ src/authorization_code_grant.ts | 11 ++++++----- src/client_credentials_grant.ts | 5 +++-- src/grant_base.ts | 13 ++++++++++--- src/refresh_token_grant.ts | 3 ++- src/resource_owner_password_credentials.ts | 5 +++-- 6 files changed, 26 insertions(+), 13 deletions(-) diff --git a/mod.ts b/mod.ts index ee198cf..7305975 100644 --- a/mod.ts +++ b/mod.ts @@ -15,6 +15,8 @@ export type { AuthorizationCodeTokenOptions, AuthorizationUri, AuthorizationUriOptions, + AuthorizationUriOptionsWithoutPKCE, + AuthorizationUriOptionsWithPKCE, AuthorizationUriWithoutVerifier, AuthorizationUriWithVerifier, } from "./src/authorization_code_grant.ts"; diff --git a/src/authorization_code_grant.ts b/src/authorization_code_grant.ts index 0a84526..bb79eba 100644 --- a/src/authorization_code_grant.ts +++ b/src/authorization_code_grant.ts @@ -4,7 +4,7 @@ import type { RequestOptions, Tokens } from "./types.ts"; import { OAuth2GrantBase } from "./grant_base.ts"; import { createPkceChallenge } from "./pkce.ts"; -interface AuthorizationUriOptionsWithPKCE { +export interface AuthorizationUriOptionsWithPKCE { /** * State parameter to send along with the authorization request. * @@ -22,7 +22,7 @@ interface AuthorizationUriOptionsWithPKCE { disablePkce?: false; } -type AuthorizationUriOptionsWithoutPKCE = +export type AuthorizationUriOptionsWithoutPKCE = & Omit & { disablePkce: true }; @@ -149,10 +149,11 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { const accessTokenResponse = await fetch(request); - return this.parseTokenResponse(accessTokenResponse); + const { tokens } = await this.parseTokenResponse(accessTokenResponse); + return tokens; } - private async validateAuthorizationResponse( + protected async validateAuthorizationResponse( url: URL, options: AuthorizationCodeTokenOptions, ): Promise<{ code: string; state?: string }> { @@ -209,7 +210,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { return { code }; } - private buildAccessTokenRequest( + protected buildAccessTokenRequest( code: string, codeVerifier?: string, requestOptions: RequestOptions = {}, diff --git a/src/client_credentials_grant.ts b/src/client_credentials_grant.ts index 17fe872..cbe7862 100644 --- a/src/client_credentials_grant.ts +++ b/src/client_credentials_grant.ts @@ -36,10 +36,11 @@ export class ClientCredentialsGrant extends OAuth2GrantBase { const accessTokenResponse = await fetch(request); - return this.parseTokenResponse(accessTokenResponse); + const { tokens } = await this.parseTokenResponse(accessTokenResponse); + return tokens; } - private buildTokenRequest( + protected buildTokenRequest( options: ClientCredentialsTokenOptions, ): Request { const { clientId, clientSecret } = this.config; diff --git a/src/grant_base.ts b/src/grant_base.ts index 7f80551..4d8a2df 100644 --- a/src/grant_base.ts +++ b/src/grant_base.ts @@ -67,7 +67,11 @@ export abstract class OAuth2GrantBase { return url; } - protected async parseTokenResponse(response: Response): Promise { + protected async parseTokenResponse( + response: Response, + ): Promise< + { tokens: Tokens; body: AccessTokenResponse & Record } + > { if (!response.ok) { throw await this.getTokenResponseError(response); } @@ -141,11 +145,14 @@ export abstract class OAuth2GrantBase { tokens.scope = body.scope.split(" "); } - return tokens; + return { + tokens, + body: body as AccessTokenResponse & Record, + }; } /** Tries to build an AuthError from the response and defaults to AuthServerResponseError if that fails. */ - private async getTokenResponseError( + protected async getTokenResponseError( response: Response, ): Promise { try { diff --git a/src/refresh_token_grant.ts b/src/refresh_token_grant.ts index b3db17f..313ec7e 100644 --- a/src/refresh_token_grant.ts +++ b/src/refresh_token_grant.ts @@ -52,6 +52,7 @@ export class RefreshTokenGrant extends OAuth2GrantBase { const accessTokenResponse = await fetch(req); - return this.parseTokenResponse(accessTokenResponse); + const { tokens } = await this.parseTokenResponse(accessTokenResponse); + return tokens; } } diff --git a/src/resource_owner_password_credentials.ts b/src/resource_owner_password_credentials.ts index a5afa44..8109865 100644 --- a/src/resource_owner_password_credentials.ts +++ b/src/resource_owner_password_credentials.ts @@ -38,10 +38,11 @@ export class ResourceOwnerPasswordCredentialsGrant extends OAuth2GrantBase { const accessTokenResponse = await fetch(request); - return this.parseTokenResponse(accessTokenResponse); + const { tokens } = await this.parseTokenResponse(accessTokenResponse); + return tokens; } - private buildTokenRequest( + protected buildTokenRequest( options: ResourceOwnerPasswordCredentialsTokenOptions, ): Request { const body: Record = { From 80952e9808862c3ce705bc44fd985c62e1e9ee72 Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 2 Aug 2023 11:48:16 +0200 Subject: [PATCH 3/7] Add prototype of an OpenID Connect client --- oidc.ts | 13 + src/oidc/authorization_code_flow.ts | 355 ++++++++++++++++++++++++++++ src/oidc/oidc_client.ts | 33 +++ src/oidc/types.ts | 40 ++++ 4 files changed, 441 insertions(+) create mode 100644 oidc.ts create mode 100644 src/oidc/authorization_code_flow.ts create mode 100644 src/oidc/oidc_client.ts create mode 100644 src/oidc/types.ts diff --git a/oidc.ts b/oidc.ts new file mode 100644 index 0000000..a350b5c --- /dev/null +++ b/oidc.ts @@ -0,0 +1,13 @@ +export type { + IDToken, + JWTHeaderParameters, + JWTPayload, + JWTVerifyResult, + OIDCTokens, +} from "./src/oidc/types.ts"; + +export { OIDCClient } from "./src/oidc/oidc_client.ts"; +export type { OIDCClientConfig } from "./src/oidc/oidc_client.ts"; + +export { AuthorizationCodeFlow } from "./src/oidc/authorization_code_flow.ts"; +export type { OIDCAuthorizationUriOptions } from "./src/oidc/authorization_code_flow.ts"; diff --git a/src/oidc/authorization_code_flow.ts b/src/oidc/authorization_code_flow.ts new file mode 100644 index 0000000..4bfed18 --- /dev/null +++ b/src/oidc/authorization_code_flow.ts @@ -0,0 +1,355 @@ +import { + AuthorizationCodeGrant, + AuthorizationCodeTokenOptions, + AuthorizationUri, + AuthorizationUriOptions, + AuthorizationUriOptionsWithoutPKCE, + AuthorizationUriOptionsWithPKCE, + AuthorizationUriWithoutVerifier, + AuthorizationUriWithVerifier, +} from "../authorization_code_grant.ts"; +import { TokenResponseError } from "../errors.ts"; +import { OIDCClientConfig } from "./oidc_client.ts"; +import { IDToken, JWTPayload, OIDCTokens } from "./types.ts"; +import { encode as base64Encode } from "https://deno.land/std@0.161.0/encoding/base64.ts"; + +type ValueOrArray = T | T[]; +function valueOrArrayToArray( + value: ValueOrArray, +): T[] { + return Array.isArray(value) ? value : [value]; +} + +/** + * https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.1 + */ +export interface OIDCAuthorizationUriOptions { + /** + * String value used to associate a Client session with an ID Token, and to + * mitigate replay attacks. The value is passed through unmodified from the + * Authentication Request to the ID Token. Sufficient entropy MUST be present + * in the nonce values used to prevent attackers from guessing values. + */ + nonce?: string; + /** + * specifies how the Authorization Server displays the authentication and consent user interface pages to the End-User. The defined values are: + * + * - page: The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page view. If the display parameter is not specified, this is the default display mode. + * - popup: The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent window. The popup User Agent window should be of an appropriate size for a login-focused dialog and should not obscure the entire window that it is popping up over. + * - The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages a touch interface. + * - The Authorization Server SHOULD display the authentication and consent UI consistent with a "feature phone" type display. + */ + display?: "page" | "popup" | "touch" | "wap"; + + /** + * specifies whether the Authorization Server prompts the End-User for reauthentication and consent. The defined values are: + * - none: The Authorization Server MUST NOT display any authentication or consent user interface pages. An error is returned if an End-User is not already authenticated or the Client does not have pre-configured consent for the requested Claims or does not fulfill other conditions for processing the request. The error code will typically be login_required, interaction_required, or another code defined in {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthError Section 3.1.2.6}. This can be used as a method to check for existing authentication and/or consent. + * - login: The Authorization Server SHOULD prompt the End-User for reauthentication. If it cannot reauthenticate the End-User, it MUST return an error, typically login_required. + * - consent: The Authorization Server SHOULD prompt the End-User for consent before returning information to the Client. If it cannot obtain consent, it MUST return an error, typically consent_required. + * - select_account: The Authorization Server SHOULD prompt the End-User to select a user account. This enables an End-User who has multiple accounts at the Authorization Server to select amongst the multiple accounts that they might have current sessions for. If it cannot obtain an account selection choice made by the End-User, it MUST return an error, typically account_selection_required. + */ + prompt?: ValueOrArray<"none" | "login" | "consent" | "select_account">; + + /** + * Maximum Authentication Age. Specifies the allowable elapsed time in + * seconds since the last time the End-User was actively authenticated by the + * OP. If the elapsed time is greater than this value, the OP MUST attempt to + * actively re-authenticate the End-User. When maxAge is used, the ID Token + * returned MUST include an auth_time Claim Value. + */ + maxAge?: number; + + /** + * End-User's preferred languages and scripts for the user interface, + * represented as a space-separated list of {@link https://www.rfc-editor.org/rfc/rfc5646 RFC5646} + * language tag values, ordered by preference. + */ + uiLocales?: ValueOrArray; + + /** + * ID Token previously issued by the Authorization Server being passed as a + * hint about the End-User's current or past authenticated session with the + * Client. If the End-User identified by the ID Token is logged in or is + * logged in by the request, then the Authorization Server returns a positive + * response; otherwise, it SHOULD return an error, such as login_required. + * When possible, an id_token_hint SHOULD be present when prompt=none is used + * and an invalid_request error MAY be returned if it is not; however, the + * server SHOULD respond successfully when possible, even if it is not + * present. The Authorization Server need not be listed as an audience of the + * ID Token when it is used as an id_token_hint value. + */ + idTokenHint?: string; + + /** + * Hint to the Authorization Server about the login identifier the End-User + * might use to log in (if necessary). This hint can be used by an RP if it + * first asks the End-User for their e-mail address (or other identifier) and + * then wants to pass that value as a hint to the discovered authorization + * service. It is RECOMMENDED that the hint value match the value used for + * discovery. This value MAY also be a phone number in the format specified + * for the phone_number Claim. + */ + loginHint?: string; + + /** + * Requested Authentication Context Class Reference values. Space-separated + * string that specifies the acr values that the Authorization Server is + * being requested to use for processing this Authentication Request, with the + * values appearing in order of preference. The Authentication Context Class + * satisfied by the authentication performed is returned as the acr Claim + * Value. + */ + acrValues?: ValueOrArray; +} + +export class AuthorizationCodeFlow extends AuthorizationCodeGrant { + protected readonly config: OIDCClientConfig; + + constructor(config: OIDCClientConfig) { + super(config); + this.config = config; + } + + public getAuthorizationUri( + options?: AuthorizationUriOptionsWithPKCE & OIDCAuthorizationUriOptions, + ): Promise; + public getAuthorizationUri( + options: AuthorizationUriOptionsWithoutPKCE & OIDCAuthorizationUriOptions, + ): Promise; + public async getAuthorizationUri( + options: AuthorizationUriOptions & OIDCAuthorizationUriOptions = {}, + ): Promise { + // this may look weird and useless, but it makes TypeScript happy + const url = + await (options.disablePkce + ? super.getAuthorizationUri(options) + : super.getAuthorizationUri(options)); + + if (typeof options.nonce !== "undefined") { + url.uri.searchParams.set("nonce", options.nonce); + } + if (typeof options.display !== "undefined") { + url.uri.searchParams.set("display", options.display); + } + if (typeof options.prompt !== "undefined") { + url.uri.searchParams.set( + "prompt", + valueOrArrayToArray(options.prompt).join(" "), + ); + } + if (typeof options.maxAge !== "undefined") { + url.uri.searchParams.set("max_age", String(options.maxAge)); + } + if (typeof options.uiLocales !== "undefined") { + url.uri.searchParams.set( + "ui_locales", + valueOrArrayToArray(options.uiLocales).join(" "), + ); + } + if (typeof options.idTokenHint !== "undefined") { + url.uri.searchParams.set("id_token_hint", options.idTokenHint); + } + if (typeof options.loginHint !== "undefined") { + url.uri.searchParams.set("login_hint", options.loginHint); + } + if (typeof options.acrValues !== "undefined") { + url.uri.searchParams.set( + "acr_values", + valueOrArrayToArray(options.acrValues).join(" "), + ); + } + + return url; + } + + public async getToken( + authResponseUri: string | URL, + options: AuthorizationCodeTokenOptions & { nonce?: string } = {}, + ): Promise { + const validated = await this.validateAuthorizationResponse( + this.toUrl(authResponseUri), + options, + ); + + const request = this.buildAccessTokenRequest( + validated.code, + options.codeVerifier, + options.requestOptions, + ); + + const tokenResponse = await fetch(request); + + const { tokens, body } = await this.parseTokenResponse(tokenResponse); + + if (!("id_token" in body)) { + throw new TokenResponseError("missing id_token", tokenResponse); + } + if (typeof body.id_token !== "string") { + throw new TokenResponseError("id_token is not a string", tokenResponse); + } + + const idTokenString = body.id_token; + const { payload: idToken, protectedHeader } = await this.config.verifyJwt( + idTokenString, + ); + + this.assertIsValidIDToken(idToken, tokenResponse, options); + requireOptionalIDTokenClaim(idToken, "at_hash", isString, tokenResponse); + if (idToken.at_hash) { + await this.validateAccessToken( + tokens.accessToken, + protectedHeader.alg, + idToken.at_hash, + tokenResponse, + ); + } + + return { + ...tokens, + idTokenString, + idToken, + }; + } + + protected async validateAccessToken( + accessToken: string, + joseAlg: string, + atHash: string, + tokenResponse: Response, + ) { + const accessTokenBytes = new TextEncoder().encode(accessToken); + + const hashAlg = { + "RS256": "SHA-256", + "RS384": "SHA-384", + "RS512": "SHA-512", + }[joseAlg]; + if (!hashAlg) { + throw new TokenResponseError( + `id_token uses unsupported algorithm for signing: ${joseAlg}`, + tokenResponse, + ); + } + + const hash = await crypto.subtle.digest(hashAlg, accessTokenBytes); + const leftHalf = hash.slice(0, hash.byteLength / 2); + const base64EncodedHash = base64Encode(leftHalf); + + if (base64EncodedHash !== atHash) { + throw new TokenResponseError( + `id_token at_hash claim does not match access_token hash`, + tokenResponse, + ); + } + } + + /** + * Performs ID token payload validation as per Section {@link https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.3.7 3.1.3.7.} + */ + protected assertIsValidIDToken( + payload: JWTPayload, + tokenResponse: Response, + tokenOptions: { nonce?: string }, + ): asserts payload is IDToken { + const currentTimestamp = Math.floor(Date.now() / 1000); + + // 3.1.3.7. ID Token Validation + requireIDTokenClaim(payload, "iss", isString, tokenResponse); + requireIDTokenClaim(payload, "sub", isString, tokenResponse); + const isValidAud = (aud: unknown): aud is string | string[] => { + if (!isStringOrStringArray(aud)) { + return false; + } + return valueOrArrayToArray(aud).some((v) => v === this.config.clientId); + }; + requireIDTokenClaim(payload, "aud", isValidAud, tokenResponse); + requireIDTokenClaim(payload, "exp", isNumber, tokenResponse); + if (payload.exp >= currentTimestamp) { + throw new TokenResponseError( + "id_token is already expired", + tokenResponse, + ); + } + + requireIDTokenClaim(payload, "iat", isNumber, tokenResponse); + requireOptionalIDTokenClaim(payload, "auth_time", isNumber, tokenResponse); + if (typeof tokenOptions.nonce === "string") { + requireIDTokenClaim( + payload, + "nonce", + (v): v is string => v === tokenOptions.nonce, + tokenResponse, + ); + } else if ("nonce" in payload) { + throw new TokenResponseError( + "id_token contained a nonce, but none was expected", + tokenResponse, + ); + } + requireOptionalIDTokenClaim(payload, "acr", isString, tokenResponse); + requireOptionalIDTokenClaim(payload, "amr", isStringArray, tokenResponse); + requireOptionalIDTokenClaim( + payload, + "azp", + (v): v is string => v === this.config.clientId, + tokenResponse, + ); + } +} + +function isString(v: unknown): v is string { + return typeof v === "string"; +} +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(isString); +} +function isStringOrStringArray(v: unknown): v is string | string[] { + return Array.isArray(v) ? v.every(isString) : isString(v); +} +function isNumber(v: unknown): v is number { + return typeof v === "number"; +} + +function requireIDTokenClaim< + P extends Record, + K extends string, + T, +>( + payload: P, + key: K, + isValid: (value: unknown) => value is T, + tokenResponse: Response, +): asserts payload is P & { [Key in K]: T } { + if (!(key in payload)) { + throw new TokenResponseError( + `id_token is missing the ${key} claim`, + tokenResponse, + ); + } + if (!isValid(payload[key])) { + throw new TokenResponseError( + `id_token contains an invalid ${key} claim`, + tokenResponse, + ); + } +} +function requireOptionalIDTokenClaim< + P extends Record, + K extends string, + T, +>( + payload: P, + key: K, + isValid: (value: unknown) => value is T, + tokenResponse: Response, +): asserts payload is P & { [Key in K]?: T } { + if (!(key in payload)) { + return; + } + if (!isValid(payload[key])) { + throw new TokenResponseError( + `id_token contains an invalid ${key} claim`, + tokenResponse, + ); + } +} diff --git a/src/oidc/oidc_client.ts b/src/oidc/oidc_client.ts new file mode 100644 index 0000000..7c670cc --- /dev/null +++ b/src/oidc/oidc_client.ts @@ -0,0 +1,33 @@ +import { OAuth2ClientConfig } from "../oauth2_client.ts"; +import { AuthorizationCodeFlow } from "./authorization_code_flow.ts"; +import { JWTVerifyResult } from "./types.ts"; + +export interface OIDCClientConfig extends OAuth2ClientConfig { + /** The URI of the client's redirection endpoint (sometimes also called callback URI). */ + redirectUri: string; + + /** + * Validates and parses the given JWT. + * + * Note that this function is also responsible for validating the JWT's + * signature + */ + verifyJwt: ( + jwt: string, + ) => Promise; +} + +export class OIDCClient { + /** + * Implements the Authorization Code Flow. + * + * See {@link https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth OpenID Connect spec, section 3.1.} + */ + public code: AuthorizationCodeFlow; + + constructor( + public readonly config: Readonly, + ) { + this.code = new AuthorizationCodeFlow(this.config); + } +} diff --git a/src/oidc/types.ts b/src/oidc/types.ts new file mode 100644 index 0000000..abb0df6 --- /dev/null +++ b/src/oidc/types.ts @@ -0,0 +1,40 @@ +import { Tokens } from "../types.ts"; + +export interface IDToken { + iss: string; + sub: string; + aud: string | string[]; + exp: number; + iat: number; + auth_time?: number; + nonce?: string; + acr?: string; + amr?: string[]; + azp?: string; + [claimName: string]: unknown; +} + +export interface OIDCTokens extends Tokens { + idTokenString: string; + idToken: IDToken; +} + +export interface JWTPayload { + iss?: string; + sub?: string; + aud?: string | string[]; + jti?: string; + nbf?: number; + exp?: number; + iat?: number; + [propName: string]: unknown; +} + +export interface JWTHeaderParameters { + alg: string; +} + +export interface JWTVerifyResult { + payload: JWTPayload; + protectedHeader: JWTHeaderParameters; +} From e30ccf20b01d92e58bf222a698ac7ae2d4890e24 Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 2 Aug 2023 12:27:06 +0200 Subject: [PATCH 4/7] Add OIDClient.getUserInfo method --- oidc.ts | 2 +- src/oidc/authorization_code_flow.ts | 25 +++---- src/oidc/oidc_client.ts | 101 +++++++++++++++++++++++++++- src/oidc/validation.ts | 38 +++++++++++ 4 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 src/oidc/validation.ts diff --git a/oidc.ts b/oidc.ts index a350b5c..3b3e68a 100644 --- a/oidc.ts +++ b/oidc.ts @@ -6,7 +6,7 @@ export type { OIDCTokens, } from "./src/oidc/types.ts"; -export { OIDCClient } from "./src/oidc/oidc_client.ts"; +export { OIDCClient, UserInfoError } from "./src/oidc/oidc_client.ts"; export type { OIDCClientConfig } from "./src/oidc/oidc_client.ts"; export { AuthorizationCodeFlow } from "./src/oidc/authorization_code_flow.ts"; diff --git a/src/oidc/authorization_code_flow.ts b/src/oidc/authorization_code_flow.ts index 4bfed18..17453b2 100644 --- a/src/oidc/authorization_code_flow.ts +++ b/src/oidc/authorization_code_flow.ts @@ -12,6 +12,13 @@ import { TokenResponseError } from "../errors.ts"; import { OIDCClientConfig } from "./oidc_client.ts"; import { IDToken, JWTPayload, OIDCTokens } from "./types.ts"; import { encode as base64Encode } from "https://deno.land/std@0.161.0/encoding/base64.ts"; +import { + isNumber, + isString, + isStringArray, + isStringOrStringArray, + optionallyIncludesClaim, +} from "./validation.ts"; type ValueOrArray = T | T[]; function valueOrArrayToArray( @@ -297,19 +304,6 @@ export class AuthorizationCodeFlow extends AuthorizationCodeGrant { } } -function isString(v: unknown): v is string { - return typeof v === "string"; -} -function isStringArray(v: unknown): v is string[] { - return Array.isArray(v) && v.every(isString); -} -function isStringOrStringArray(v: unknown): v is string | string[] { - return Array.isArray(v) ? v.every(isString) : isString(v); -} -function isNumber(v: unknown): v is number { - return typeof v === "number"; -} - function requireIDTokenClaim< P extends Record, K extends string, @@ -343,10 +337,7 @@ function requireOptionalIDTokenClaim< isValid: (value: unknown) => value is T, tokenResponse: Response, ): asserts payload is P & { [Key in K]?: T } { - if (!(key in payload)) { - return; - } - if (!isValid(payload[key])) { + if (!optionallyIncludesClaim(payload, key, isValid)) { throw new TokenResponseError( `id_token contains an invalid ${key} claim`, tokenResponse, diff --git a/src/oidc/oidc_client.ts b/src/oidc/oidc_client.ts index 7c670cc..cf2c83d 100644 --- a/src/oidc/oidc_client.ts +++ b/src/oidc/oidc_client.ts @@ -1,11 +1,15 @@ import { OAuth2ClientConfig } from "../oauth2_client.ts"; import { AuthorizationCodeFlow } from "./authorization_code_flow.ts"; -import { JWTVerifyResult } from "./types.ts"; +import { IDToken, JWTVerifyResult } from "./types.ts"; +import { includesClaim, isObject } from "./validation.ts"; export interface OIDCClientConfig extends OAuth2ClientConfig { /** The URI of the client's redirection endpoint (sometimes also called callback URI). */ redirectUri: string; + /** The UserInfo endpoint of the authorization server. */ + userInfoEndpoint?: string; + /** * Validates and parses the given JWT. * @@ -30,4 +34,99 @@ export class OIDCClient { ) { this.code = new AuthorizationCodeFlow(this.config); } + + async getUserInfo( + accessToken: string, + idToken: IDToken, + options: { requestHeaders?: HeadersInit } = {}, + ) { + if (typeof this.config.userInfoEndpoint !== "string") { + throw new UserInfoError( + "calling getUserInfo() requires a userInfoEndpoint to be configured", + ); + } + const requestHeaders = new Headers(options.requestHeaders); + requestHeaders.set("Authorization", `Bearer ${accessToken}`); + const response = await fetch(this.config.userInfoEndpoint, { + headers: requestHeaders, + }); + + if (!response.ok) { + // TODO: parse error response (https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.3) + throw new UserInfoError("userinfo returned an error"); + } + + const userInfoPayload = await this.getUserInfoResponsePayload( + response.clone(), + ); + + if ( + !includesClaim( + userInfoPayload, + "sub", + (sub): sub is string => sub === idToken.sub, + ) + ) { + throw new UserInfoError( + "the userInfo response body contained an invalid `sub` claim", + ); + } + + return userInfoPayload; + } + + protected async getUserInfoResponsePayload( + response: Response, + ): Promise> { + const contentType = response.headers.get("Content-Type"); + const jsonContentType = "application/json"; + const jwtContentType = "application/jwt"; + + switch (contentType) { + case jsonContentType: { + let responseBody: unknown; + try { + responseBody = await response.json(); + } catch { + throw new UserInfoError( + "the userinfo response body was not valid JSON", + ); + } + if (!isObject(responseBody)) { + throw new UserInfoError( + "the userinfo response body was not a JSON object", + ); + } + return responseBody; + } + case jwtContentType: { + let responseBody: string; + try { + responseBody = await response.text(); + } catch { + throw new UserInfoError(`failed to read ${jwtContentType} response`); + } + + try { + const { payload } = await this.config.verifyJwt(responseBody); + return payload; + } catch { + throw new UserInfoError( + `failed to validate the userinfo JWT response`, + ); + } + } + default: + throw new UserInfoError( + `the userinfo response had an invalid content-type. Expected ${jsonContentType} or ${jwtContentType}, but got ${contentType}`, + ); + } + } +} + +/** Thrown when there was an error while requesting data from the UserInfo endpoint */ +export class UserInfoError extends Error { + constructor(message: string) { + super(message); + } } diff --git a/src/oidc/validation.ts b/src/oidc/validation.ts new file mode 100644 index 0000000..6c148e6 --- /dev/null +++ b/src/oidc/validation.ts @@ -0,0 +1,38 @@ +export function isString(v: unknown): v is string { + return typeof v === "string"; +} +export function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(isString); +} +export function isStringOrStringArray(v: unknown): v is string | string[] { + return Array.isArray(v) ? v.every(isString) : isString(v); +} +export function isNumber(v: unknown): v is number { + return typeof v === "number"; +} +export function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function includesClaim< + P extends Record, + K extends string, + T, +>( + payload: P, + key: K, + isValid: (value: unknown) => value is T, +): payload is P & { [Key in K]: T } { + return (key in payload) && isValid(payload[key]); +} +export function optionallyIncludesClaim< + P extends Record, + K extends string, + T, +>( + payload: P, + key: K, + isValid: (value: unknown) => value is T, +): payload is P & { [Key in K]?: T } { + return !(key in payload) || isValid(payload[key]); +} From 3b9a604f90653ee5e40cdeea8da11ef78a29bc3f Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 23 Aug 2023 12:12:32 +0200 Subject: [PATCH 5/7] Fix OIDC access token hash validation --- src/oidc/authorization_code_flow.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/oidc/authorization_code_flow.ts b/src/oidc/authorization_code_flow.ts index 17453b2..35acd88 100644 --- a/src/oidc/authorization_code_flow.ts +++ b/src/oidc/authorization_code_flow.ts @@ -240,9 +240,9 @@ export class AuthorizationCodeFlow extends AuthorizationCodeGrant { const hash = await crypto.subtle.digest(hashAlg, accessTokenBytes); const leftHalf = hash.slice(0, hash.byteLength / 2); - const base64EncodedHash = base64Encode(leftHalf); + const base64EncodedHash = base64Encode(leftHalf).replaceAll("=", ""); - if (base64EncodedHash !== atHash) { + if (base64EncodedHash !== atHash.replaceAll("=", "")) { throw new TokenResponseError( `id_token at_hash claim does not match access_token hash`, tokenResponse, @@ -271,7 +271,7 @@ export class AuthorizationCodeFlow extends AuthorizationCodeGrant { }; requireIDTokenClaim(payload, "aud", isValidAud, tokenResponse); requireIDTokenClaim(payload, "exp", isNumber, tokenResponse); - if (payload.exp >= currentTimestamp) { + if (payload.exp <= currentTimestamp) { throw new TokenResponseError( "id_token is already expired", tokenResponse, From 76ae9c9a455a95018f865c7c1653b4ce7acb1c61 Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 23 Aug 2023 12:12:59 +0200 Subject: [PATCH 6/7] Fix content-type check in getUserInfo --- src/oidc/oidc_client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/oidc/oidc_client.ts b/src/oidc/oidc_client.ts index cf2c83d..bf37729 100644 --- a/src/oidc/oidc_client.ts +++ b/src/oidc/oidc_client.ts @@ -78,7 +78,11 @@ export class OIDCClient { protected async getUserInfoResponsePayload( response: Response, ): Promise> { - const contentType = response.headers.get("Content-Type"); + const contentType = response.headers.get("Content-Type") + ?.split(";") + .at(0) + ?.trimEnd(); + const jsonContentType = "application/json"; const jwtContentType = "application/jwt"; From 0f9f3d241cbfda4bf1c0cc31a0c7de6c2d34f467 Mon Sep 17 00:00:00 2001 From: Jonas Auer Date: Wed, 23 Aug 2023 12:13:41 +0200 Subject: [PATCH 7/7] Add OIDC examples --- examples/oidc/http.ts | 103 ++++++++++++++++++++++++++++++++++++++++ examples/oidc/oak.ts | 107 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 examples/oidc/http.ts create mode 100644 examples/oidc/oak.ts diff --git a/examples/oidc/http.ts b/examples/oidc/http.ts new file mode 100644 index 0000000..09ebb85 --- /dev/null +++ b/examples/oidc/http.ts @@ -0,0 +1,103 @@ +import { serve } from "https://deno.land/std@0.161.0/http/server.ts"; +import { + Cookie, + deleteCookie, + getCookies, + setCookie, +} from "https://deno.land/std@0.161.0/http/cookie.ts"; +import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts"; +import { OIDCClient } from "../../src/oidc/oidc_client.ts"; + +const jwks = jose.createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +const oidcClient = new OIDCClient({ + clientId: Deno.env.get("CLIENT_ID")!, + clientSecret: Deno.env.get("CLIENT_SECRET")!, + authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUri: "https://oauth2.googleapis.com/token", + redirectUri: "http://localhost:8000/oauth2/callback", + userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo", + defaults: { + scope: ["openid", "email", "profile"], + }, + verifyJwt: (jwt) => jose.jwtVerify(jwt, jwks), +}); + +/** This is where we'll store our state and PKCE codeVerifiers */ +const loginStates = new Map(); +/** The name we'll use for the session cookie */ +const cookieName = "session"; + +/** Handles incoming HTTP requests */ +function handler(req: Request): Promise | Response { + const url = new URL(req.url); + const path = url.pathname; + + switch (path) { + case "/login": + return redirectToAuthEndpoint(); + case "/oauth2/callback": + return handleCallback(req); + default: + return new Response("Not Found", { status: 404 }); + } +} + +async function redirectToAuthEndpoint(): Promise { + // Generate a random state + const state = crypto.randomUUID(); + + const { uri, codeVerifier } = await oidcClient.code.getAuthorizationUri({ + state, + }); + + // Associate the state and PKCE codeVerifier with a session cookie + const sessionId = crypto.randomUUID(); + loginStates.set(sessionId, { state, codeVerifier }); + const sessionCookie: Cookie = { + name: cookieName, + value: sessionId, + httpOnly: true, + sameSite: "Lax", + }; + const headers = new Headers({ Location: uri.toString() }); + setCookie(headers, sessionCookie); + + // Redirect to the authorization endpoint + return new Response(null, { status: 302, headers }); +} + +async function handleCallback(req: Request): Promise { + // Load the state and PKCE codeVerifier associated with the session + const sessionCookie = getCookies(req.headers)[cookieName]; + const loginState = sessionCookie && loginStates.get(sessionCookie); + if (!loginState) { + throw new Error("invalid session"); + } + loginStates.delete(sessionCookie); + + // Exchange the authorization code for an access token + const tokens = await oidcClient.code.getToken(req.url, loginState); + + const userInfo = await oidcClient.getUserInfo( + tokens.accessToken, + tokens.idToken, + ); + + // Clear the session cookie since we don't need it anymore + const headers = new Headers(); + deleteCookie(headers, cookieName); + return new Response( + `

Hello, ${userInfo.name}!

`, + { + headers: { + "content-type": "text/html", + }, + }, + ); +} + +// Start the app +serve(handler, { port: 8000 }); diff --git a/examples/oidc/oak.ts b/examples/oidc/oak.ts new file mode 100644 index 0000000..a6ca86d --- /dev/null +++ b/examples/oidc/oak.ts @@ -0,0 +1,107 @@ +import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts"; +import { + MemoryStore, + Session, +} from "https://deno.land/x/oak_sessions@v4.0.5/mod.ts"; +import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts"; +import { OIDCClient } from "../../src/oidc/oidc_client.ts"; + +const jwks = jose.createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +const oidcClient = new OIDCClient({ + clientId: Deno.env.get("CLIENT_ID")!, + clientSecret: Deno.env.get("CLIENT_SECRET")!, + authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUri: "https://oauth2.googleapis.com/token", + redirectUri: "http://localhost:8000/oauth2/callback", + userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo", + defaults: { + scope: ["openid", "email", "profile"], + }, + verifyJwt: (jwt) => jose.jwtVerify(jwt, jwks), +}); + +type AppState = { + session: Session; +}; + +const router = new Router(); +router.get("/login", async (ctx) => { + // Generate a random state for this login event + const state = crypto.randomUUID(); + + // Construct the URL for the authorization redirect and get a PKCE codeVerifier + const { uri, codeVerifier } = await oidcClient.code.getAuthorizationUri({ + state, + }); + + // Store both the state and codeVerifier in the user session + ctx.state.session.flash("state", state); + ctx.state.session.flash("codeVerifier", codeVerifier); + + // Redirect the user to the authorization endpoint + ctx.response.redirect(uri); +}); +router.get("/oauth2/callback", async (ctx) => { + // Make sure both a state and codeVerifier are present for the user's session + const state = ctx.state.session.get("state"); + if (typeof state !== "string") { + throw new Error("invalid state"); + } + + const codeVerifier = ctx.state.session.get("codeVerifier"); + if (typeof codeVerifier !== "string") { + throw new Error("invalid codeVerifier"); + } + + // Exchange the authorization code for an access token + const tokens = await oidcClient.code.getToken(ctx.request.url, { + state, + codeVerifier, + }); + + // Use the userinfo endpoint to get more information about the user + const userInfo = await oidcClient.getUserInfo( + tokens.accessToken, + tokens.idToken, + ); + + ctx.response.headers.set("Content-Type", "text/html"); + ctx.response.body = + `

Hello, ${userInfo.name}!

`; +}); + +const app = new Application(); + +// Add a key for signing cookies +app.keys = ["super-secret-key"]; + +// Set up the session middleware +const sessionStore = new MemoryStore(); +app.use(Session.initMiddleware(sessionStore, { + cookieSetOptions: { + httpOnly: true, + sameSite: "lax", + // Enable for when running outside of localhost + // secure: true, + signed: true, + }, + cookieGetOptions: { + signed: true, + }, + expireAfterSeconds: 60 * 10, +})); + +// Mount the router +app.use(router.allowedMethods(), router.routes()); + +// Start the app +const port = 8000; +app.addEventListener("listen", () => { + console.log( + `App listening on port ${port}. Navigate to http://localhost:${port}/login to log in!`, + ); +}); +await app.listen({ port });