@badgateway/oauth2-client
Version:
OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support
558 lines (461 loc) • 16.9 kB
text/typescript
import type { OAuth2Token } from './token.ts';
import type {
AuthorizationCodeRequest,
ClientCredentialsRequest,
IntrospectionRequest,
IntrospectionResponse,
PasswordRequest,
OAuth2TokenTypeHint,
RefreshRequest,
RevocationRequest,
ServerMetadataResponse,
TokenResponse,
} from './messages.ts';
import { OAuth2HttpError } from './error.ts';
import { OAuth2AuthorizationCodeClient } from './client/authorization-code.ts';
type ClientCredentialsParams = {
scope?: string[];
extraParams?: Record<string, string>;
/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}
type PasswordParams = {
username: string;
password: string;
scope?: string[];
/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}
/**
* Extra options that may be passed to refresh()
*/
type RefreshParams = {
scope?: string[];
/**
* The resource the client intends to access.
*
* @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.
*
* If this is not desired, just specify the other endpoints manually.
*
* This url will also be used as the base URL for all other urls. This lets
* you specify all the other urls as relative.
*/
server?: string;
/**
* OAuth2 clientId
*/
clientId: string;
/**
* OAuth2 clientSecret
*
* This is required when using the 'client_secret_basic' authenticationMethod
* for the client_credentials and password flows, but not authorization_code
* or implicit.
*/
clientSecret?: string;
/**
* The /authorize endpoint.
*
* Required only for the browser-portion of the authorization_code flow.
*/
authorizationEndpoint?: string;
/**
* The token endpoint.
*
* Required for most grant types and refreshing tokens.
*/
tokenEndpoint?: string;
/**
* Introspection endpoint.
*
* Required for, well, introspecting tokens.
* If not provided we'll try to discover it, or otherwise default to /introspect
*/
introspectionEndpoint?: string;
/**
* Revocation endpoint.
*
* Required for revoking tokens. Not supported by all servers.
* If not provided we'll try to discover it, or otherwise default to /revoke
*/
revocationEndpoint?: string;
/**
* OAuth 2.0 Authorization Server Metadata endpoint or OpenID
* Connect Discovery 1.0 endpoint.
*
* If this endpoint is provided it can be used to automatically figure
* out all the other endpoints.
*
* Usually the URL for this is: https://server/.well-known/oauth-authorization-server
*/
discoveryEndpoint?: string;
/**
* Fetch implementation to use.
*
* Set this if you wish to explicitly set the fetch implementation, e.g. to
* implement middlewares or set custom headers.
*/
fetch?: typeof fetch;
/**
* Client authentication method that is used to authenticate
* when using the token endpoint.
*
* When 'client_secret_basic' is used, the client_id and client_secret are
* encoded in the Authorization header, as per RFC 6749 section 2.3.1. This
* uses the official encoding, which also percent-encodes special characters.
*
* Many popular servers don't expect this, despite being the standard. So we
* also support 'client_secret_basic_interop', which does not percent-encode
* special characters except ":". This is 'interop' encoding is the default
* for this library to maximize compatibility.
*
* In the future, we will switch this to 'client_secret_post', which has fewer
* interopability issues. This setting causes the client to provide the
* client_id and secret in the POST body.
*
* The default value is 'client_secret_basic' if not provided.
*/
authenticationMethod?: 'client_secret_basic' | 'client_secret_post' | 'client_secret_basic_interop';
}
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<OAuth2Token> {
if (!token.refreshToken) {
throw new Error('This token didn\'t have a refreshToken. It\'s not possible to refresh this');
}
const body: RefreshRequest = {
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
};
if (!this.settings.clientSecret) {
// If there's no secret, send the clientId in the body.
body.client_id = this.settings.clientId;
}
if (params?.scope) body.scope = params.scope.join(' ');
if (params?.resource) body.resource = params.resource;
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<OAuth2Token> {
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(' '),
resource: params?.resource,
...params?.extraParams
};
if (!this.settings.clientSecret) {
throw new Error('A clientSecret must be provided to use client_credentials');
}
return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
}
/**
* Retrieves an OAuth2 token using the 'password' grant'.
*/
async password(params: PasswordParams): Promise<OAuth2Token> {
const body: PasswordRequest = {
grant_type: 'password',
...params,
scope: params.scope?.join(' '),
};
return this.tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
}
/**
* Returns the helper object for the `authorization_code` grant.
*/
get authorizationCode(): OAuth2AuthorizationCodeClient {
return new OAuth2AuthorizationCodeClient(
this,
);
}
/**
* Introspect a token
*
* This will give information about the validity, owner, which client
* created the token and more.
*
* @see https://datatracker.ietf.org/doc/html/rfc7662
*/
async introspect(token: OAuth2Token): Promise<IntrospectionResponse> {
const body: IntrospectionRequest = {
token: token.accessToken,
token_type_hint: 'access_token',
};
return this.request('introspectionEndpoint', body);
}
/**
* Revoke a token
*
* This will revoke a token, provided that the server supports this feature.
*
* @see https://datatracker.ietf.org/doc/html/rfc7009
*/
async revoke(token: OAuth2Token, tokenTypeHint: OAuth2TokenTypeHint = 'access_token'): Promise<void> {
let tokenValue = token.accessToken;
if (tokenTypeHint === 'refresh_token') {
tokenValue = token.refreshToken!;
}
const body: RevocationRequest = {
token: tokenValue,
token_type_hint: tokenTypeHint,
};
return this.request('revocationEndpoint', body);
}
/**
* Returns a url for an OAuth2 endpoint.
*
* Potentially fetches a discovery document to get it.
*/
async getEndpoint(endpoint: OAuth2Endpoint): Promise<string> {
if (this.settings[endpoint] !== undefined) {
return resolve(this.settings[endpoint] as string, this.settings.server);
}
if (endpoint !== 'discoveryEndpoint') {
// This condition prevents infinite loops.
await this.discover();
if (this.settings[endpoint] !== undefined) {
return resolve(this.settings[endpoint] as string, this.settings.server);
}
}
// 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.`);
}
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);
}
}
private discoveryDone = false;
private serverMetadata: ServerMetadataResponse | null = null;
/**
* Fetches the OAuth2 discovery document
*/
private async discover(): Promise<void> {
// Never discover twice
if (this.discoveryDone) return;
this.discoveryDone = true;
let discoverUrl;
try {
discoverUrl = await this.getEndpoint('discoveryEndpoint');
} catch (_err) {
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' }});
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');
return;
}
this.serverMetadata = await resp.json();
const urlMap = [
['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);
}
if (
this.serverMetadata.token_endpoint_auth_methods_supported
&& !this.settings.authenticationMethod
) {
for(const method of this.serverMetadata.token_endpoint_auth_methods_supported) {
if (method === 'client_secret_basic' || method === 'client_secret_post') {
this.settings.authenticationMethod = method;
break;
}
}
}
}
/**
* Does a HTTP request on the 'token' endpoint.
*/
async request(endpoint: 'tokenEndpoint', body: RefreshRequest | ClientCredentialsRequest | PasswordRequest | AuthorizationCodeRequest): Promise<TokenResponse>;
async request(endpoint: 'introspectionEndpoint', body: IntrospectionRequest): Promise<IntrospectionResponse>;
async request(endpoint: 'revocationEndpoint', body: RevocationRequest): Promise<void>;
async request(endpoint: OAuth2Endpoint, body: Record<string, any>): Promise<unknown> {
const uri = await this.getEndpoint(endpoint);
const headers: Record<string, string> = {
'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',
};
let authMethod = this.settings.authenticationMethod;
if (!this.settings.clientSecret) {
// 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';
}
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_interop';
}
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(legacyFormUrlEncode(this.settings.clientId) + ':' + legacyFormUrlEncode(this.settings.clientSecret!));
break;
case 'client_secret_basic_interop' :
// A more relaxed encoding that's more compatible with popular servers.
headers.Authorization = 'Basic ' +
btoa(this.settings.clientId.replace(/:/g, '%3A') + ':' + this.settings.clientSecret!.replace(/:/g, '%3A'));
break;
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!');
}
const resp = await this.settings.fetch!(uri, {
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/)) {
responseBody = await resp.json();
}
if (resp.ok) {
return responseBody;
}
let errorMessage;
let oauth2Code;
if (responseBody?.error) {
// This is likely an OAUth2-formatted error
errorMessage = 'OAuth2 error ' + responseBody.error + '.';
if (responseBody.error_description) {
errorMessage += ' ' + responseBody.error_description;
}
oauth2Code = responseBody.error;
} else {
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';
}
oauth2Code = null;
}
throw new OAuth2HttpError(errorMessage, oauth2Code, resp, responseBody);
}
/**
* Converts the JSON response body from the token endpoint to an OAuth2Token type.
*/
async tokenResponseToOAuth2Token(resp: Promise<TokenResponse>): Promise<OAuth2Token> {
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.');
}
const {
access_token,
refresh_token,
expires_in,
id_token,
scope,
token_type,
...extraParams
} = body;
const result: OAuth2Token = {
accessToken: access_token,
expiresAt: expires_in ? Date.now() + (expires_in * 1000) : null,
refreshToken: refresh_token ?? null,
};
if (id_token) {
result.idToken = id_token;
}
if (scope) {
result.scope = scope.split(' ');
}
if (Object.keys(extraParams).length > 0) {
result.extraParams = extraParams;
}
return result;
}
}
function resolve(uri: string, base?: string): string {
return new URL(uri, base).toString();
}
/**
* Generates a query 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, undefined | number | string | string[]>): 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);
} else if (v !== undefined) query.set(k, v.toString());
}
return query.toString();
}
/**
* Encodes string according to the most strict interpretation of RFC 6749 Appendix B.
*
* All non-alphanumeric characters are percent encoded, with exception of space which
* is replaced with '+'.
*/
export function legacyFormUrlEncode(value: string): string {
return encodeURIComponent(value)
.replace(/%20/g, '+')
.replace(/[-_.!~*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}