Skip to content

Commit

Permalink
Add OAuth2 client_credentials grant client
Browse files Browse the repository at this point in the history
  • Loading branch information
p2004a committed Jun 8, 2024
1 parent 9db3a91 commit 8c81def
Show file tree
Hide file tree
Showing 4 changed files with 287 additions and 5 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions src/oauth2Client.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
160 changes: 160 additions & 0 deletions src/oauth2Client.ts
Original file line number Diff line number Diff line change
@@ -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<OAuth2Metadata> = {
$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<OAuth2Error> = {
$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<OAuth2AccessToken> = {
$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<string> {
// 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;
}

0 comments on commit 8c81def

Please sign in to comment.