-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add OAuth2 client_credentials grant client
- Loading branch information
Showing
4 changed files
with
287 additions
and
5 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/, | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |