From 8c81defa85269f11f25c50dc7559590688eedd5e Mon Sep 17 00:00:00 2001 From: Marek Rusinowski Date: Sat, 8 Jun 2024 20:32:30 +0200 Subject: [PATCH] Add OAuth2 client_credentials grant client --- package-lock.json | 4 +- package.json | 6 +- src/oauth2Client.test.ts | 122 +++++++++++++++++++++++++++++ src/oauth2Client.ts | 160 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 src/oauth2Client.test.ts create mode 100644 src/oauth2Client.ts diff --git a/package-lock.json b/package-lock.json index 2ee5e0a..18c98a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "ajv": "^8.14.0", + "ajv-formats": "^3.0.1", "fastify": "^4.27.0", "recoil-tdf": "^1.0.0", "tiny-typed-emitter": "^2.1.0", @@ -24,8 +26,6 @@ "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/parser": "^7.11.0", - "ajv": "^8.14.0", - "ajv-formats": "^3.0.1", "eslint": "8.57.0", "eslint-config-prettier": "^9.1.0", "json-schema-to-typescript": "^14.0.4", diff --git a/package.json b/package.json index aea4599..a142de4 100644 --- a/package.json +++ b/package.json @@ -21,23 +21,23 @@ "node": ">=18.20" }, "dependencies": { + "ajv": "^8.14.0", + "ajv-formats": "^3.0.1", "fastify": "^4.27.0", "recoil-tdf": "^1.0.0", "tiny-typed-emitter": "^2.1.0", "ws": "^8.17.0" }, "devDependencies": { - "@fastify/type-provider-json-schema-to-ts": "^3.0.0", "@fastify/basic-auth": "^5.1.1", "@fastify/formbody": "^7.4.0", + "@fastify/type-provider-json-schema-to-ts": "^3.0.0", "@fastify/websocket": "^10.0.1", "@tsconfig/node20": "^20.1.4", "@types/node": "^20.12.13", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/parser": "^7.11.0", - "ajv": "^8.14.0", - "ajv-formats": "^3.0.1", "eslint": "8.57.0", "eslint-config-prettier": "^9.1.0", "json-schema-to-typescript": "^14.0.4", diff --git a/src/oauth2Client.test.ts b/src/oauth2Client.test.ts new file mode 100644 index 0000000..ca18b5f --- /dev/null +++ b/src/oauth2Client.test.ts @@ -0,0 +1,122 @@ +import test, { after, beforeEach } from 'node:test'; +import { equal } from 'node:assert/strict'; + +import { getAccessToken } from './oauth2Client.js'; + +import Fastify from 'fastify'; +import FastifyFormBody from '@fastify/formbody'; +import { rejects } from 'node:assert'; + +const server = Fastify(); +await server.register(FastifyFormBody); + +const failHandler: Fastify.RouteHandler = async (_req, resp) => { + resp.code(500); + return 'fail'; +}; + +let metadataHandler: Fastify.RouteHandler = failHandler; +let tokenHandler: Fastify.RouteHandler = failHandler; +beforeEach(() => { + metadataHandler = failHandler; + tokenHandler = failHandler; +}); + +server.get('/.well-known/oauth-authorization-server', (req, resp) => + metadataHandler.call(server, req, resp), +); +server.post('/oauth2/token', (req, resp) => tokenHandler.call(server, req, resp)); + +await server.listen(); +const PORT = server.addresses()[0].port; + +after(() => server.close()); + +test('simple full example', async () => { + metadataHandler = async () => { + return { + issuer: `http://localhost:${PORT}`, + token_endpoint: `http://localhost:${PORT}/oauth2/token`, + response_types_supported: ['token'], + }; + }; + let tokenErr: Error | undefined; + tokenHandler = async (req) => { + try { + equal(req.headers.authorization, 'Basic dXNlcjE6cGFzczE='); + equal(req.headers['content-type'], 'application/x-www-form-urlencoded'); + const params = new URLSearchParams(req.body as string); + equal(params.get('grant_type'), 'client_credentials'); + equal(params.get('scope'), 'tachyon.lobby'); + } catch (error) { + tokenErr = error as Error; + } + return { + access_token: 'token_value', + token_type: 'Bearer', + expires_in: 60, + }; + }; + const token = await getAccessToken( + `http://localhost:${PORT}`, + 'user1', + 'pass1', + 'tachyon.lobby', + ); + if (tokenErr) throw tokenErr; + equal(token, 'token_value'); +}); + +test('wrong oauth2 metadata', async () => { + metadataHandler = async () => { + return { + issuer: `http://localhost:${PORT}`, + }; + }; + await rejects( + () => getAccessToken(`http://localhost:${PORT}`, 'user1', 'pass1', 'tachyon.lobby'), + /Invalid.*object/, + ); +}); + +test('propagates OAuth2 error message', async () => { + metadataHandler = async () => { + return { + issuer: `http://localhost:${PORT}`, + token_endpoint: `http://localhost:${PORT}/oauth2/token`, + response_types_supported: ['token'], + }; + }; + tokenHandler = async (_req, resp) => { + resp.code(400); + return { + error: 'invalid_scope', + error_description: 'Invalid scope', + }; + }; + await rejects( + () => getAccessToken(`http://localhost:${PORT}`, 'user1', 'pass1', 'tachyon.lobby'), + /invalid_scope.*Invalid scope/, + ); +}); + +test('bad access token response', async () => { + metadataHandler = async () => { + return { + issuer: `http://localhost:${PORT}`, + token_endpoint: `http://localhost:${PORT}/oauth2/token`, + response_types_supported: ['token'], + }; + }; + tokenHandler = async () => { + return { + access_token: 'token_value', + token_type: 'CustomType', + expires_in: 60, + }; + }; + await rejects( + () => getAccessToken(`http://localhost:${PORT}`, 'user1', 'pass1', 'tachyon.lobby'), + /expected Bearer/, + ); +}); diff --git a/src/oauth2Client.ts b/src/oauth2Client.ts new file mode 100644 index 0000000..7999cd9 --- /dev/null +++ b/src/oauth2Client.ts @@ -0,0 +1,160 @@ +/** + * A very simple OAuth2 client that can fetch access tokens using the client credentials grant. + * + * This client is designed to be used in a server-to-server context where the client is a server + * application that needs to authenticate with another server application using OAuth2. + */ +import { Ajv, JSONSchemaType } from 'ajv'; + +// Let's define different OAuth2 schemas so we can use automatic and strict validation of the +// OAuth2 metadata, errors and access tokens, instead of using conditions. The schemas are very +// small and simple, so it's not a big deal to just define them inline here. With the help of +// JSONSchemaType, the interface and schema statically checked to match. + +interface OAuth2Metadata { + issuer: string; + token_endpoint: string | null; + response_types_supported: string[]; +} + +const OAuth2MetadataSchema: JSONSchemaType = { + $id: 'OAuth2Metadata', + type: 'object', + properties: { + issuer: { type: 'string' }, + token_endpoint: { type: 'string' }, + response_types_supported: { type: 'array', items: { type: 'string' } }, + }, + required: ['issuer', 'response_types_supported'], + additionalProperties: true, +}; + +interface OAuth2Error { + error: string; + error_description: string | null; + error_uri: string | null; + state: string | null; +} + +const OAuth2ErrorSchema: JSONSchemaType = { + $id: 'OAuth2Error', + type: 'object', + properties: { + error: { type: 'string' }, + error_description: { type: 'string' }, + error_uri: { type: 'string' }, + state: { type: 'string' }, + }, + required: ['error'], + additionalProperties: true, +}; + +interface OAuth2AccessToken { + access_token: string; + token_type: string; + expires_in: number | null; + scope: string | null; +} + +const OAuth2AccessTokenSchema: JSONSchemaType = { + $id: 'OAuthAccessToken', + type: 'object', + properties: { + access_token: { type: 'string' }, + token_type: { type: 'string' }, + expires_in: { type: 'number' }, + scope: { type: 'string' }, + }, + required: ['access_token', 'token_type'], +}; + +const ajv = new Ajv({ strict: true }); +const validateOAuth2Metadata = ajv.compile(OAuth2MetadataSchema); +const validateOAuth2Error = ajv.compile(OAuth2ErrorSchema); +const validateOAuth2AccessToken = ajv.compile(OAuth2AccessTokenSchema); + +/** + * Fetches a new OAuth2 access token from the OAuth2 server. The only grant type supported + * is `client_credentials` and the only token type supported is `Bearer`. + * + * @param baseUrl The base url of the OAuth2 server, e.g. `https://example.com:8132` + * @param clientId The client id to use for authentication + * @param clientSecret The client secret to use for authentication + * @param scope The scope to request, or none to use the default scope + * @returns The Bearer access token to use for further requests + */ +export async function getAccessToken( + baseUrl: string, + clientId: string, + clientSecret: string, + scope?: string, +): Promise { + // Fetch the metadata to find the token endpoint. + // + // TODO: Add caching of the metadata according to caching parameters from server + // so we don't fetch it every single time. + const oauth2metaResp = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`); + if (!oauth2metaResp.ok) { + throw new Error(`Failed to fetch OAuth2 metadata (${oauth2metaResp.status})`); + } + const oauth2meta = await oauth2metaResp.json(); + if (!validateOAuth2Metadata(oauth2meta)) { + const errs = ajv.errorsText(validateOAuth2Metadata.errors); + throw new Error(`Invalid OAuth2 Authorization Server Metadata object: ${errs}`); + } + if (!oauth2meta.response_types_supported.includes('token') || !oauth2meta.token_endpoint) { + throw new Error('OAuth2 server does not support token endpoint'); + } + + // Now let's try to get a new access token + const basicToken = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const tokenParams = new URLSearchParams({ + grant_type: 'client_credentials', + }); + if (scope) { + tokenParams.append('scope', scope); + } + const tokenResp = await fetch(oauth2meta.token_endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'authorization': `Basic ${basicToken}`, + }, + body: tokenParams, + }); + if (!tokenResp.ok) { + let tokenRespBody: unknown; + try { + tokenRespBody = await tokenResp.json(); + } catch { + throw new Error(`Failed to fetch OAuth2 token (${tokenResp.status})`); + } + if (validateOAuth2Error(tokenRespBody)) { + let msg = `Failed to fetch OAuth2 token (${tokenResp.status}): ${tokenRespBody.error}`; + if (tokenRespBody.error_description) { + msg += `: ${tokenRespBody.error_description}`; + } + throw new Error(msg); + } + } + + let tokenRespBody: unknown; + try { + tokenRespBody = await tokenResp.json(); + } catch { + throw new Error(`Failed to parse OAuth2 token response body as json`); + } + if (!validateOAuth2AccessToken(tokenRespBody)) { + const errs = ajv.errorsText(validateOAuth2AccessToken.errors); + throw new Error(`Invalid OAuth2 Access Token object: ${errs}`); + } + if (tokenRespBody.token_type !== 'Bearer') { + throw new Error( + `Unsupported OAuth2 Access Token type: ${tokenRespBody.token_type}, expected Bearer`, + ); + } + + // TODO: Add caching of the access token so we don't fetch a fresh one every single time. + + return tokenRespBody.access_token; +}