From 6df6dfcc7c9ab89339def495d5b18100df6cb8f9 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Wed, 27 Sep 2023 15:32:38 +1000 Subject: [PATCH] BREAKING: flatten `OAuth2ClientConfig` --- README.md | 4 +- examples/http.ts | 4 +- examples/oak.ts | 4 +- src/authorization_code_grant.ts | 4 +- src/authorization_code_grant_test.ts | 76 ++++++++----------- src/client_credentials_grant.ts | 2 +- src/client_credentials_grant_test.ts | 33 ++++---- src/grant_base.ts | 4 +- src/grant_base_test.ts | 32 ++++---- src/implicit_grant.ts | 4 +- src/implicit_grant_test.ts | 75 ++++++++---------- src/oauth2_client.ts | 22 +++--- src/resource_owner_password_credentials.ts | 2 +- ...esource_owner_password_credentials_test.ts | 36 ++++----- 14 files changed, 131 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index c0af36c..2b1adcc 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,7 @@ const oauth2Client = new OAuth2Client({ authorizationEndpointUri: "https://github.com/login/oauth/authorize", tokenUri: "https://github.com/login/oauth/access_token", redirectUri: "http://localhost:8000/oauth2/callback", - defaults: { - scope: "read:user", - }, + scope: "read:user", }); type AppState = { diff --git a/examples/http.ts b/examples/http.ts index 62ba77e..03de0c1 100644 --- a/examples/http.ts +++ b/examples/http.ts @@ -13,9 +13,7 @@ const oauth2Client = new OAuth2Client({ authorizationEndpointUri: "https://github.com/login/oauth/authorize", tokenUri: "https://github.com/login/oauth/access_token", redirectUri: "http://localhost:8000/oauth2/callback", - defaults: { - scope: "read:user", - }, + scope: "read:user", }); /** This is where we'll store our state and PKCE codeVerifiers */ diff --git a/examples/oak.ts b/examples/oak.ts index 65832e5..334e720 100644 --- a/examples/oak.ts +++ b/examples/oak.ts @@ -11,9 +11,7 @@ const oauth2Client = new OAuth2Client({ authorizationEndpointUri: "https://github.com/login/oauth/authorize", tokenUri: "https://github.com/login/oauth/access_token", redirectUri: "http://localhost:8000/oauth2/callback", - defaults: { - scope: "read:user", - }, + scope: "read:user", }); type AppState = { diff --git a/src/authorization_code_grant.ts b/src/authorization_code_grant.ts index 699a23f..b83721f 100644 --- a/src/authorization_code_grant.ts +++ b/src/authorization_code_grant.ts @@ -102,7 +102,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase { if (typeof this.client.config.redirectUri === "string") { params.set("redirect_uri", this.client.config.redirectUri); } - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.client.config.scope; if (scope) { params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); } @@ -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.client.config.stateValidator; if (stateValidator && !await stateValidator(state)) { if (state === null) { diff --git a/src/authorization_code_grant_test.ts b/src/authorization_code_grant_test.ts index 20c5dd4..04bee17 100644 --- a/src/authorization_code_grant_test.ts +++ b/src/authorization_code_grant_test.ts @@ -148,7 +148,7 @@ Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses default scopes if no scope was specified", async () => { const { uri, codeVerifier } = await getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, + scope: ["default", "scopes"], }).code.getAuthorizationUri(); const codeChallenge = uri.searchParams.get("code_challenge"); @@ -164,7 +164,7 @@ Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses default scopes if no Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses specified scopes over default scopes", async () => { const { uri, codeVerifier } = await getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, + scope: ["default", "scopes"], }).code.getAuthorizationUri({ scope: "notDefault", }); @@ -257,21 +257,19 @@ Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses default scopes if no scope was specified with PKCE disabled", async () => { assertMatchesUrl( - (await getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).code.getAuthorizationUri({ disablePkce: true })).uri, + (await getOAuth2Client({ scope: ["default", "scopes"] }).code + .getAuthorizationUri({ disablePkce: true })).uri, "https://auth.server/auth?response_type=code&client_id=clientId&scope=default+scopes", ); }); Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses specified scopes over default scopes with PKCE disabled", async () => { assertMatchesUrl( - (await getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).code.getAuthorizationUri({ - scope: "notDefault", - disablePkce: true, - })).uri, + (await getOAuth2Client({ scope: ["default", "scopes"] }).code + .getAuthorizationUri({ + scope: "notDefault", + disablePkce: true, + })).uri, "https://auth.server/auth?response_type=code&client_id=clientId&scope=notDefault", ); }); @@ -756,15 +754,13 @@ Deno.test("AuthorizationCodeGrant.getToken uses the default request options", as const { request } = await mockATResponse( () => getOAuth2Client({ - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).code.getToken(buildAccessTokenCallback({ params: { code: "authCode" }, @@ -781,15 +777,13 @@ Deno.test("AuthorizationCodeGrant.getToken uses the passed request options over const { request } = await mockATResponse( () => getOAuth2Client({ - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).code.getToken( buildAccessTokenCallback({ @@ -819,11 +813,11 @@ Deno.test("AuthorizationCodeGrant.getToken uses the default state validator if n await mockATResponse( () => - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).code.getToken(buildAccessTokenCallback({ - params: { code: "authCode", state: "some_state" }, - })), + getOAuth2Client({ stateValidator: defaultValidator }).code.getToken( + buildAccessTokenCallback({ + params: { code: "authCode", state: "some_state" }, + }), + ), ); assertSpyCall(defaultValidator, 0, { args: ["some_state"], returned: true }); @@ -835,11 +829,11 @@ Deno.test("AuthorizationCodeGrant.getToken supports async default state validato await mockATResponse( () => - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).code.getToken(buildAccessTokenCallback({ - params: { code: "authCode", state: "some_state" }, - })), + getOAuth2Client({ stateValidator: defaultValidator }).code.getToken( + buildAccessTokenCallback({ + params: { code: "authCode", state: "some_state" }, + }), + ), ); assertSpyCallAsync(defaultValidator, 0, { @@ -855,9 +849,7 @@ Deno.test("AuthorizationCodeGrant.getToken uses the passed state validator over await mockATResponse( () => - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).code.getToken( + getOAuth2Client({ stateValidator: defaultValidator }).code.getToken( buildAccessTokenCallback({ params: { code: "authCode", state: "some_state" }, }), @@ -876,9 +868,7 @@ Deno.test("AuthorizationCodeGrant.getToken uses the passed state validator over await mockATResponse( () => - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).code.getToken( + getOAuth2Client({ stateValidator: defaultValidator }).code.getToken( buildAccessTokenCallback({ params: { code: "authCode", state: "some_state" }, }), diff --git a/src/client_credentials_grant.ts b/src/client_credentials_grant.ts index 3f4de01..fb7fe83 100644 --- a/src/client_credentials_grant.ts +++ b/src/client_credentials_grant.ts @@ -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.client.config.scope; if (scope) { if (Array.isArray(scope)) { body.scope = scope.join(" "); diff --git a/src/client_credentials_grant_test.ts b/src/client_credentials_grant_test.ts index b58de08..56cb503 100644 --- a/src/client_credentials_grant_test.ts +++ b/src/client_credentials_grant_test.ts @@ -294,7 +294,7 @@ Deno.test("ClientCredentialsGrant.getToken includes default scopes in the token () => getOAuth2Client({ clientSecret: "secret", - defaults: { scope: ["default", "scopes"] }, + scope: ["default", "scopes"], }).clientCredentials.getToken(), ); @@ -319,7 +319,7 @@ Deno.test("ClientCredentialsGrant.getToken does not include default scopes in th () => getOAuth2Client({ clientSecret: "secret", - defaults: { scope: ["default", "scopes"] }, + scope: ["default", "scopes"], }).clientCredentials.getToken({ scope: "notDefault" }), ); @@ -344,15 +344,14 @@ Deno.test("ClientCredentialsGrant.getToken uses the default request options", as () => getOAuth2Client({ clientSecret: "secret", - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).clientCredentials.getToken(), ); @@ -368,15 +367,13 @@ Deno.test("ClientCredentialsGrant.getToken uses the passed request options over () => getOAuth2Client({ clientSecret: "secret", - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).clientCredentials.getToken({ requestOptions: { diff --git a/src/grant_base.ts b/src/grant_base.ts index 5c4fb74..7a2532d 100644 --- a/src/grant_base.ts +++ b/src/grant_base.ts @@ -27,7 +27,7 @@ export abstract class OAuth2GrantBase { ): Request { const url = this.toUrl(baseUrl); - const clientDefaults = this.client.config.defaults?.requestOptions; + const clientDefaults = this.client.config.requestOptions; const urlParams: Record = { ...(clientDefaults?.urlParams), @@ -61,7 +61,7 @@ export abstract class OAuth2GrantBase { } protected toUrl(url: string | URL): URL { - if (typeof (url) === "string") { + if (typeof url === "string") { return new URL(url, "http://example.com"); } return url; diff --git a/src/grant_base_test.ts b/src/grant_base_test.ts index b033dba..93726a5 100644 --- a/src/grant_base_test.ts +++ b/src/grant_base_test.ts @@ -39,23 +39,21 @@ Deno.test("OAuth2GrantBase.buildRequest works without optional parameters", asyn Deno.test("OAuth2GrantBase.buildRequest works with overrideOptions set", async () => { const req = getGrantBase({ - defaults: { - requestOptions: { - body: { - default1: "default", - default2: "default", - default3: "default", - }, - headers: { - "default-header1": "default", - "default-header2": "default", - "default-header3": "default", - }, - urlParams: { - "defaultParam1": "default", - "defaultParam2": "default", - "defaultParam3": "default", - }, + requestOptions: { + body: { + default1: "default", + default2: "default", + default3: "default", + }, + headers: { + "default-header1": "default", + "default-header2": "default", + "default-header3": "default", + }, + urlParams: { + "defaultParam1": "default", + "defaultParam2": "default", + "defaultParam3": "default", }, }, }).buildRequest("https://auth.server/req", { diff --git a/src/implicit_grant.ts b/src/implicit_grant.ts index a5e064e..a3b239e 100644 --- a/src/implicit_grant.ts +++ b/src/implicit_grant.ts @@ -52,7 +52,7 @@ export class ImplicitGrant extends OAuth2GrantBase { if (typeof this.client.config.redirectUri === "string") { params.set("redirect_uri", this.client.config.redirectUri); } - const scope = options.scope ?? this.client.config.defaults?.scope; + const scope = options.scope ?? this.client.config.scope; if (scope) { params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); } @@ -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.client.config.stateValidator; const tokens: Tokens = { accessToken, diff --git a/src/implicit_grant_test.ts b/src/implicit_grant_test.ts index 9c82d56..a51f6d4 100644 --- a/src/implicit_grant_test.ts +++ b/src/implicit_grant_test.ts @@ -86,20 +86,18 @@ Deno.test("ImplicitGrant.getAuthorizationUri works with redirectUri and multiple Deno.test("ImplicitGrant.getAuthorizationUri uses default scopes if no scope was specified", () => { assertMatchesUrl( - getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).implicit.getAuthorizationUri(), + getOAuth2Client({ scope: ["default", "scopes"] }).implicit + .getAuthorizationUri(), "https://auth.server/auth?response_type=token&client_id=clientId&scope=default+scopes", ); }); Deno.test("ImplicitGrant.getAuthorizationUri uses specified scopes over default scopes", () => { assertMatchesUrl( - getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).implicit.getAuthorizationUri({ - scope: "notDefault", - }), + getOAuth2Client({ scope: ["default", "scopes"] }).implicit + .getAuthorizationUri({ + scope: "notDefault", + }), "https://auth.server/auth?response_type=token&client_id=clientId&scope=notDefault", ); }); @@ -238,13 +236,12 @@ Deno.test("ImplicitGrant.getToken throws if it didn't receive a state and the as Deno.test("ImplicitGrant.getToken throws if it didn't receive a state and the default async state validator fails", async () => { await assertRejects( () => - getOAuth2Client({ - defaults: { stateValidator: () => Promise.resolve(false) }, - }).implicit.getToken( - buildImplicitAccessTokenCallback({ - params: { access_token: "at", token_type: "Bearer" }, - }), - ), + getOAuth2Client({ stateValidator: () => Promise.resolve(false) }).implicit + .getToken( + buildImplicitAccessTokenCallback({ + params: { access_token: "at", token_type: "Bearer" }, + }), + ), AuthorizationResponseError, "missing state", ); @@ -389,15 +386,15 @@ Deno.test("ImplicitGrant.getToken doesn't throw if it didn't receive a state but Deno.test("ImplicitGrant.getToken uses the default state validator if no state or validator was given", async () => { const defaultValidator = spy(() => true); - await getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).implicit.getToken(buildImplicitAccessTokenCallback({ - params: { - access_token: "accessToken", - token_type: "tokenType", - state: "some_state", - }, - })); + await getOAuth2Client({ stateValidator: defaultValidator }).implicit.getToken( + buildImplicitAccessTokenCallback({ + params: { + access_token: "accessToken", + token_type: "tokenType", + state: "some_state", + }, + }), + ); assertSpyCall(defaultValidator, 0, { args: ["some_state"], returned: true }); assertSpyCalls(defaultValidator, 1); @@ -406,15 +403,15 @@ Deno.test("ImplicitGrant.getToken uses the default state validator if no state o Deno.test("ImplicitGrant.getToken uses the default async state validator if no state or validator was given", async () => { const defaultValidator = spy(() => Promise.resolve(true)); - await getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).implicit.getToken(buildImplicitAccessTokenCallback({ - params: { - access_token: "accessToken", - token_type: "tokenType", - state: "some_state", - }, - })); + await getOAuth2Client({ stateValidator: defaultValidator }).implicit.getToken( + buildImplicitAccessTokenCallback({ + params: { + access_token: "accessToken", + token_type: "tokenType", + state: "some_state", + }, + }), + ); assertSpyCallAsync(defaultValidator, 0, { args: ["some_state"], @@ -427,9 +424,7 @@ Deno.test("ImplicitGrant.getToken uses the passed state validator over the defau const defaultValidator = spy(() => true); const validator = spy(() => true); - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).implicit.getToken( + getOAuth2Client({ stateValidator: defaultValidator }).implicit.getToken( buildImplicitAccessTokenCallback({ params: { access_token: "accessToken", @@ -449,9 +444,7 @@ Deno.test("ImplicitGrant.getToken uses the passed async state validator over the const defaultValidator = spy(() => true); const validator = spy(() => Promise.resolve(true)); - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).implicit.getToken( + getOAuth2Client({ stateValidator: defaultValidator }).implicit.getToken( buildImplicitAccessTokenCallback({ params: { access_token: "accessToken", @@ -471,9 +464,7 @@ Deno.test("ImplicitGrant.getToken uses the passed state validator over the passe const defaultValidator = spy(() => true); const validator = spy(() => true); - getOAuth2Client({ - defaults: { stateValidator: defaultValidator }, - }).implicit.getToken( + getOAuth2Client({ stateValidator: defaultValidator }).implicit.getToken( buildImplicitAccessTokenCallback({ params: { access_token: "accessToken", diff --git a/src/oauth2_client.ts b/src/oauth2_client.ts index 0471ba7..1d5faab 100644 --- a/src/oauth2_client.ts +++ b/src/oauth2_client.ts @@ -18,18 +18,16 @@ export interface OAuth2ClientConfig { /** The URI of the authorization server's token endpoint. */ tokenUri: string; - defaults?: { - /** - * Default request options to use when performing outgoing HTTP requests. - * - * For example used when exchanging authorization codes for access tokens. - */ - requestOptions?: Omit; - /** Default scopes to request unless otherwise specified. */ - scope?: string | string[]; - /** Default state validator to use for validating the authorization response's state value. */ - stateValidator?: (state: string | null) => Promise | boolean; - }; + /** + * Default request options to use when performing outgoing HTTP requests. + * + * For example used when exchanging authorization codes for access tokens. + */ + requestOptions?: Omit; + /** Default scopes to request unless otherwise specified. */ + scope?: string | string[]; + /** Default state validator to use for validating the authorization response's state value. */ + stateValidator?: (state: string | null) => Promise | boolean; } export class OAuth2Client { diff --git a/src/resource_owner_password_credentials.ts b/src/resource_owner_password_credentials.ts index acf08c5..622fa83 100644 --- a/src/resource_owner_password_credentials.ts +++ b/src/resource_owner_password_credentials.ts @@ -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.client.config.scope; if (scope) { if (Array.isArray(scope)) { body.scope = scope.join(" "); diff --git a/src/resource_owner_password_credentials_test.ts b/src/resource_owner_password_credentials_test.ts index 9c1d478..04d4290 100644 --- a/src/resource_owner_password_credentials_test.ts +++ b/src/resource_owner_password_credentials_test.ts @@ -269,9 +269,7 @@ Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken builds a correct reque Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses default scopes if no scope was specified", async () => { const { request } = await mockATResponse( () => - getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).ropc.getToken({ + getOAuth2Client({ scope: ["default", "scopes"] }).ropc.getToken({ username: "un", password: "pw", }), @@ -294,9 +292,7 @@ Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses default scopes if Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses specified scopes over default scopes", async () => { const { request } = await mockATResponse( () => - getOAuth2Client({ - defaults: { scope: ["default", "scopes"] }, - }).ropc.getToken({ + getOAuth2Client({ scope: ["default", "scopes"] }).ropc.getToken({ username: "un", password: "pw", scope: "notDefault", @@ -385,15 +381,13 @@ Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses the default reque const { request } = await mockATResponse( () => getOAuth2Client({ - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).ropc.getToken({ username: "un", password: "pw" }), ); @@ -408,15 +402,13 @@ Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses the passed reques const { request } = await mockATResponse( () => getOAuth2Client({ - defaults: { - requestOptions: { - headers: { - "User-Agent": "Custom User Agent", - "Content-Type": "application/json", - }, - urlParams: { "custom-url-param": "value" }, - body: { "custom-body-param": "value" }, + requestOptions: { + headers: { + "User-Agent": "Custom User Agent", + "Content-Type": "application/json", }, + urlParams: { "custom-url-param": "value" }, + body: { "custom-body-param": "value" }, }, }).ropc.getToken( {