From 8edebd8a072ab1bc470b0668440fc996f5c1b6be Mon Sep 17 00:00:00 2001 From: drev74 Date: Sun, 9 Feb 2025 16:11:21 +0300 Subject: [PATCH] test: add test for ID token chore: upd ID token type declaration --- src/client.ts | 280 ++++++++++++++++++++++++++++--------------------- src/token.ts | 3 +- test/client.ts | 50 ++++++--- 3 files changed, 199 insertions(+), 134 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4ad28c3..978fa8e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import { OAuth2Token } from './token.js'; +import { OAuth2Token } from "./token.js"; import { AuthorizationCodeRequest, ClientCredentialsRequest, @@ -10,10 +10,9 @@ import { RevocationRequest, ServerMetadataResponse, TokenResponse, -} from './messages.js'; -import { OAuth2HttpError } from './error.js'; -import { OAuth2AuthorizationCodeClient } from './client/authorization-code.js'; - +} from "./messages.js"; +import { OAuth2HttpError } from "./error.js"; +import { OAuth2AuthorizationCodeClient } from "./client/authorization-code.js"; type ClientCredentialsParams = { scope?: string[]; @@ -25,7 +24,7 @@ type ClientCredentialsParams = { * @see https://datatracker.ietf.org/doc/html/rfc8707 */ resource?: string | string[]; -} +}; type PasswordParams = { username: string; @@ -39,8 +38,7 @@ type PasswordParams = { * @see https://datatracker.ietf.org/doc/html/rfc8707 */ resource?: string | string[]; - -} +}; /** * Extra options that may be passed to refresh() @@ -54,11 +52,9 @@ type RefreshParams = { * @see https://datatracker.ietf.org/doc/html/rfc8707 */ resource?: string | string[]; - -} +}; export interface ClientSettings { - /** * The hostname of the OAuth2 server. * If provided, we'll attempt to discover all the other related endpoints. @@ -144,33 +140,38 @@ export interface ClientSettings { authenticationMethod?: string; } - -type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint' | 'revocationEndpoint'; +type OAuth2Endpoint = + | "tokenEndpoint" + | "authorizationEndpoint" + | "discoveryEndpoint" + | "introspectionEndpoint" + | "revocationEndpoint"; export class OAuth2Client { - settings: ClientSettings; constructor(clientSettings: ClientSettings) { - if (!clientSettings?.fetch) { clientSettings.fetch = fetch.bind(globalThis); } this.settings = clientSettings; - } /** * Refreshes an existing token, and returns a new one. */ - async refreshToken(token: OAuth2Token, params?: RefreshParams): Promise { - + async refreshToken( + token: OAuth2Token, + params?: RefreshParams + ): Promise { if (!token.refreshToken) { - throw new Error('This token didn\'t have a refreshToken. It\'s not possible to refresh this'); + throw new Error( + "This token didn't have a refreshToken. It's not possible to refresh this" + ); } const body: RefreshRequest = { - grant_type: 'refresh_token', + grant_type: "refresh_token", refresh_token: token.refreshToken, }; if (!this.settings.clientSecret) { @@ -178,67 +179,70 @@ export class OAuth2Client { body.client_id = this.settings.clientId; } - if (params?.scope) body.scope = params.scope.join(' '); + if (params?.scope) body.scope = params.scope.join(" "); if (params?.resource) body.resource = params.resource; - const newToken = await this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body)); + const newToken = await this.tokenResponseToOAuth2Token( + this.request("tokenEndpoint", body) + ); if (!newToken.refreshToken && token.refreshToken) { // Reuse old refresh token if we didn't get a new one. newToken.refreshToken = token.refreshToken; } return newToken; - } /** * Retrieves an OAuth2 token using the client_credentials grant. */ - async clientCredentials(params?: ClientCredentialsParams): Promise { - - const disallowed = ['client_id', 'client_secret', 'grant_type', 'scope']; - - if (params?.extraParams && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) { - throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`); + async clientCredentials( + params?: ClientCredentialsParams + ): Promise { + const disallowed = ["client_id", "client_secret", "grant_type", "scope"]; + + if ( + params?.extraParams && + Object.keys(params.extraParams).filter((key) => disallowed.includes(key)) + .length > 0 + ) { + throw new Error( + `The following extraParams are disallowed: '${disallowed.join("', '")}'` + ); } const body: ClientCredentialsRequest = { - grant_type: 'client_credentials', - scope: params?.scope?.join(' '), + grant_type: "client_credentials", + scope: params?.scope?.join(" "), resource: params?.resource, - ...params?.extraParams + ...params?.extraParams, }; if (!this.settings.clientSecret) { - throw new Error('A clientSecret must be provided to use client_credentials'); + throw new Error( + "A clientSecret must be provided to use client_credentials" + ); } - return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body)); - + return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", body)); } /** * Retrieves an OAuth2 token using the 'password' grant'. */ async password(params: PasswordParams): Promise { - const body: PasswordRequest = { - grant_type: 'password', + grant_type: "password", ...params, - scope: params.scope?.join(' '), + scope: params.scope?.join(" "), }; - return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body)); - + return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", body)); } /** * Returns the helper object for the `authorization_code` grant. */ get authorizationCode(): OAuth2AuthorizationCodeClient { - - return new OAuth2AuthorizationCodeClient( - this, - ); - + return new OAuth2AuthorizationCodeClient(this); } /** @@ -250,13 +254,11 @@ export class OAuth2Client { * @see https://datatracker.ietf.org/doc/html/rfc7662 */ async introspect(token: OAuth2Token): Promise { - const body: IntrospectionRequest = { token: token.accessToken, - token_type_hint: 'access_token', + token_type_hint: "access_token", }; - return this.request('introspectionEndpoint', body); - + return this.request("introspectionEndpoint", body); } /** @@ -266,9 +268,12 @@ export class OAuth2Client { * * @see https://datatracker.ietf.org/doc/html/rfc7009 */ - async revoke(token: OAuth2Token, tokenTypeHint: OAuth2TokenTypeHint = 'access_token'): Promise { + async revoke( + token: OAuth2Token, + tokenTypeHint: OAuth2TokenTypeHint = "access_token" + ): Promise { let tokenValue = token.accessToken; - if (tokenTypeHint === 'refresh_token') { + if (tokenTypeHint === "refresh_token") { tokenValue = token.refreshToken!; } @@ -276,8 +281,7 @@ export class OAuth2Client { token: tokenValue, token_type_hint: tokenTypeHint, }; - return this.request('revocationEndpoint', body); - + return this.request("revocationEndpoint", body); } /** @@ -286,12 +290,11 @@ export class OAuth2Client { * Potentially fetches a discovery document to get it. */ async getEndpoint(endpoint: OAuth2Endpoint): Promise { - if (this.settings[endpoint] !== undefined) { return resolve(this.settings[endpoint] as string, this.settings.server); } - if (endpoint !== 'discoveryEndpoint') { + if (endpoint !== "discoveryEndpoint") { // This condition prevents infinite loops. await this.discover(); if (this.settings[endpoint] !== undefined) { @@ -301,88 +304,117 @@ export class OAuth2Client { // If we got here it means we need to 'guess' the endpoint. if (!this.settings.server) { - throw new Error(`Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.`); + throw new Error( + `Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.` + ); } switch (endpoint) { - case 'authorizationEndpoint': - return resolve('/authorize', this.settings.server); - case 'tokenEndpoint': - return resolve('/token', this.settings.server); - case 'discoveryEndpoint': - return resolve('/.well-known/oauth-authorization-server', this.settings.server); - case 'introspectionEndpoint': - return resolve('/introspect', this.settings.server); - case 'revocationEndpoint': - return resolve('/revoke', this.settings.server); + case "authorizationEndpoint": + return resolve("/authorize", this.settings.server); + case "tokenEndpoint": + return resolve("/token", this.settings.server); + case "discoveryEndpoint": + return resolve( + "/.well-known/oauth-authorization-server", + this.settings.server + ); + case "introspectionEndpoint": + return resolve("/introspect", this.settings.server); + case "revocationEndpoint": + return resolve("/revoke", this.settings.server); } - } private discoveryDone = false; private serverMetadata: ServerMetadataResponse | null = null; - /** * Fetches the OAuth2 discovery document */ private async discover(): Promise { - // Never discover twice if (this.discoveryDone) return; this.discoveryDone = true; let discoverUrl; try { - discoverUrl = await this.getEndpoint('discoveryEndpoint'); + discoverUrl = await this.getEndpoint("discoveryEndpoint"); } catch (_err) { - console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint'); + console.warn( + '[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint' + ); return; } - const resp = await this.settings.fetch!(discoverUrl, { headers: { Accept: 'application/json' }}); + const resp = await this.settings.fetch!(discoverUrl, { + headers: { Accept: "application/json" }, + }); if (!resp.ok) return; - if (!resp.headers.get('Content-Type')?.startsWith('application/json')) { - console.warn('[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored'); + if (!resp.headers.get("Content-Type")?.startsWith("application/json")) { + console.warn( + "[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored" + ); return; } this.serverMetadata = await resp.json(); const urlMap = [ - ['authorization_endpoint', 'authorizationEndpoint'], - ['token_endpoint', 'tokenEndpoint'], - ['introspection_endpoint', 'introspectionEndpoint'], - ['revocation_endpoint', 'revocationEndpoint'], + ["authorization_endpoint", "authorizationEndpoint"], + ["token_endpoint", "tokenEndpoint"], + ["introspection_endpoint", "introspectionEndpoint"], + ["revocation_endpoint", "revocationEndpoint"], ] as const; if (this.serverMetadata === null) return; for (const [property, setting] of urlMap) { if (!this.serverMetadata[property]) continue; - this.settings[setting] = resolve(this.serverMetadata[property]!, discoverUrl); + this.settings[setting] = resolve( + this.serverMetadata[property]!, + discoverUrl + ); } - if (this.serverMetadata.token_endpoint_auth_methods_supported && !this.settings.authenticationMethod) { - this.settings.authenticationMethod = this.serverMetadata.token_endpoint_auth_methods_supported[0]; + if ( + this.serverMetadata.token_endpoint_auth_methods_supported && + !this.settings.authenticationMethod + ) { + this.settings.authenticationMethod = + this.serverMetadata.token_endpoint_auth_methods_supported[0]; } - } /** * Does a HTTP request on the 'token' endpoint. */ - async request(endpoint: 'tokenEndpoint', body: RefreshRequest | ClientCredentialsRequest | PasswordRequest | AuthorizationCodeRequest): Promise; - async request(endpoint: 'introspectionEndpoint', body: IntrospectionRequest): Promise; - async request(endpoint: 'revocationEndpoint', body: RevocationRequest): Promise; - async request(endpoint: OAuth2Endpoint, body: Record): Promise { - + async request( + endpoint: "tokenEndpoint", + body: + | RefreshRequest + | ClientCredentialsRequest + | PasswordRequest + | AuthorizationCodeRequest + ): Promise; + async request( + endpoint: "introspectionEndpoint", + body: IntrospectionRequest + ): Promise; + async request( + endpoint: "revocationEndpoint", + body: RevocationRequest + ): Promise; + async request( + endpoint: OAuth2Endpoint, + body: Record + ): Promise { const uri = await this.getEndpoint(endpoint); const headers: Record = { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", // Although it shouldn't be needed, Github OAUth2 will return JSON // unless this is set. - 'Accept': 'application/json', + Accept: "application/json", }; let authMethod = this.settings.authenticationMethod; @@ -391,41 +423,54 @@ export class OAuth2Client { // Basic auth should only be used when there's a client_secret, for // non-confidential clients we may only have a client_id, which // always gets added to the body. - authMethod = 'client_secret_post'; + authMethod = "client_secret_post"; } if (!authMethod) { // If we got here, it means no preference was provided by anything, // and we have a secret. In this case its preferred to embed // authentication in the Authorization header. - authMethod = 'client_secret_basic'; + authMethod = "client_secret_basic"; } - switch(authMethod) { - case 'client_secret_basic' : + switch (authMethod) { + case "client_secret_basic": // Per RFC 6749 section 2.3.1, the client_id and client_secret need // to be encoded using application/x-www-form-urlencoded for the // basic auth. - headers.Authorization = 'Basic ' + - btoa(encodeURIComponent(this.settings.clientId) + ':' + encodeURIComponent(this.settings.clientSecret!)); + headers.Authorization = + "Basic " + + btoa( + encodeURIComponent(this.settings.clientId) + + ":" + + encodeURIComponent(this.settings.clientSecret!) + ); break; - case 'client_secret_post' : + case "client_secret_post": body.client_id = this.settings.clientId; if (this.settings.clientSecret) { body.client_secret = this.settings.clientSecret; } break; default: - throw new Error('Authentication method not yet supported:' + authMethod + '. Open a feature request if you want this!'); + throw new Error( + "Authentication method not yet supported:" + + authMethod + + ". Open a feature request if you want this!" + ); } const resp = await this.settings.fetch!(uri, { - method: 'POST', + method: "POST", body: generateQueryString(body), headers, }); let responseBody; - if (resp.status !== 204 && resp.headers.has('Content-Type') && resp.headers.get('Content-Type')!.match(/^application\/(.*\+)?json/)) { + if ( + resp.status !== 204 && + resp.headers.has("Content-Type") && + resp.headers.get("Content-Type")!.match(/^application\/(.*\+)?json/) + ) { responseBody = await resp.json(); } if (resp.ok) { @@ -437,16 +482,16 @@ export class OAuth2Client { if (responseBody?.error) { // This is likely an OAUth2-formatted error - errorMessage = 'OAuth2 error ' + responseBody.error + '.'; + errorMessage = "OAuth2 error " + responseBody.error + "."; if (responseBody.error_description) { - errorMessage += ' ' + responseBody.error_description; + errorMessage += " " + responseBody.error_description; } oauth2Code = responseBody.error; - } else { - errorMessage = 'HTTP Error ' + resp.status + ' ' + resp.statusText; + errorMessage = "HTTP Error " + resp.status + " " + resp.statusText; if (resp.status === 401 && this.settings.clientSecret) { - errorMessage += '. It\'s likely that the clientId and/or clientSecret was incorrect'; + errorMessage += + ". It's likely that the clientId and/or clientSecret was incorrect"; } oauth2Code = null; } @@ -456,30 +501,29 @@ export class OAuth2Client { /** * Converts the JSON response body from the token endpoint to an OAuth2Token type. */ - async tokenResponseToOAuth2Token(resp: Promise): Promise { - + async tokenResponseToOAuth2Token( + resp: Promise + ): Promise { const body = await resp; if (!body?.access_token) { - console.warn('Invalid OAuth2 Token Response: ', body); - throw new TypeError('We received an invalid token response from an OAuth2 server.'); + console.warn("Invalid OAuth2 Token Response: ", body); + throw new TypeError( + "We received an invalid token response from an OAuth2 server." + ); } return { accessToken: body.access_token, - idToken: body.id_token ?? null, - expiresAt: body.expires_in ? Date.now() + (body.expires_in * 1000) : null, + idToken: body.id_token, + expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : null, refreshToken: body.refresh_token ?? null, }; - } - } function resolve(uri: string, base?: string): string { - return new URL(uri, base).toString(); - } /** @@ -488,14 +532,14 @@ function resolve(uri: string, base?: string): string { * If a value is undefined, it will be ignored. * If a value is an array, it will add the parameter multiple times for each array value. */ -export function generateQueryString(params: Record): string { - +export function generateQueryString( + params: Record +): string { const query = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (Array.isArray(v)) { - for(const vItem of v) query.append(k, vItem); + for (const vItem of v) query.append(k, vItem); } else if (v !== undefined) query.set(k, v.toString()); } return query.toString(); - } diff --git a/src/token.ts b/src/token.ts index 83af771..a969d4d 100644 --- a/src/token.ts +++ b/src/token.ts @@ -2,7 +2,6 @@ * Token information */ export type OAuth2Token = { - /** * OAuth2 Access Token */ @@ -23,5 +22,5 @@ export type OAuth2Token = { /** * OAuth2 ID Token */ - idToken: string | null; + idToken?: string; }; diff --git a/test/client.ts b/test/client.ts index cfebb82..702bd1c 100644 --- a/test/client.ts +++ b/test/client.ts @@ -1,36 +1,58 @@ -import * as assert from 'node:assert'; -import { OAuth2Client } from '../src/index.js'; -import { describe, it } from 'node:test'; +import * as assert from "node:assert"; +import { OAuth2Client } from "../src/index.js"; +import { describe, it } from "node:test"; -describe('tokenResponseToOAuth2Token', () => { - it('should convert a JSON response to a OAuth2Token', async () => { +describe("tokenResponseToOAuth2Token", () => { + it("should convert a JSON response to a OAuth2Token", async () => { const client = new OAuth2Client({ - clientId: 'foo', + clientId: "foo", }); const token = await client.tokenResponseToOAuth2Token( Promise.resolve({ - token_type: 'bearer', - access_token: 'foo-bar', + token_type: "bearer", + access_token: "foo-bar", }) ); assert.deepEqual(token, { - accessToken: 'foo-bar', + accessToken: "foo-bar", expiresAt: null, refreshToken: null, + idToken: undefined, }); }); - it('should error when an invalid JSON object is passed', async () => { + it("should response with Access, Refresh and ID Tokens", async () => { const client = new OAuth2Client({ - clientId: 'foo', + clientId: "foo", + }); + const token = await client.tokenResponseToOAuth2Token( + Promise.resolve({ + token_type: "bearer", + access_token: "foo-bar", + refresh_token: "baz", + id_token: "maz", + }) + ); + + assert.deepEqual(token, { + accessToken: "foo-bar", + idToken: "maz", + refreshToken: "baz", + expiresAt: null, + }); + }); + + it("should error when an invalid JSON object is passed", async () => { + const client = new OAuth2Client({ + clientId: "foo", }); let caught = false; try { await client.tokenResponseToOAuth2Token( Promise.resolve({ - funzies: 'foo-bar', + funzies: "foo-bar", } as any) ); } catch (err) { @@ -40,9 +62,9 @@ describe('tokenResponseToOAuth2Token', () => { assert.equal(caught, true); }); - it('should error when an empty body is passed', async () => { + it("should error when an empty body is passed", async () => { const client = new OAuth2Client({ - clientId: 'foo', + clientId: "foo", }); let caught = false;