@oa2/core
Version:
A comprehensive, RFC-compliant OAuth 2.0 authorization server implementation in TypeScript
495 lines (492 loc) • 25.5 kB
JavaScript
import { UnauthorizedClientError, InvalidRequestError, InvalidGrantError, AccessDeniedError } from './errors.js';
import { parseRequestBody, validateCodeVerifier, createS256Challenge, validateRedirectUri, validateScope, generateRandomString } from './utils.js';
/**
* Helper function to generate both access and refresh tokens using a token strategy
*/ async function generateTokenPair(tokenStrategy, params, context) {
const accessTokenResult = await tokenStrategy.generateAccessToken(params, context);
const refreshTokenResult = await tokenStrategy.generateRefreshToken(params, context);
return {
accessToken: accessTokenResult.accessToken,
accessTokenExpiresAt: accessTokenResult.accessTokenExpiresAt,
refreshToken: refreshTokenResult.refreshToken,
refreshTokenExpiresAt: refreshTokenResult.refreshTokenExpiresAt,
scope: accessTokenResult.scope,
clientId: accessTokenResult.clientId,
userId: accessTokenResult.userId
};
}
/**
* Handles the OAuth 2.0 Authorization Endpoint request.
* This function validates the authorization request and redirects the user-agent
* back to the client with an authorization code.
* @param context The context object containing the request, storage, and authenticated client.
* @returns A Promise that resolves to an OAuth2Response, typically a 302 redirect.
* @throws {UnauthorizedClientError} If the client is not authenticated.
* @throws {InvalidRequestError} If required parameters are missing or invalid.
* @throws {AccessDeniedError} If the resource owner denies the request or authentication fails.
*/ /**
* Implements the Authorization Code Grant flow with PKCE support.
* This grant type is used by confidential and public clients to exchange an authorization code
* for an access token and optionally a refresh token.
* @returns A Grant object for the authorization_code type.
*/ function authorizationCodeGrant(options) {
return {
type: 'authorization_code',
responseTypes: [
'code'
],
handleAuthorization: async (context)=>{
const { request, storage, client, config } = context;
/**
* RFC 6749, Section 4.1.2.1 Error Response
* "unauthorized_client: The client is not authorized to request an authorization
* code using this method."
*/ if (!client) {
throw new UnauthorizedClientError('Client not authenticated for authorization_code grant');
}
const { redirect_uri, scope, state, code_challenge, code_challenge_method } = request.query;
// 1. Validate redirect_uri
/**
* RFC 6749, Section 4.1.2.1 Error Response
* "If the request fails due to a missing, invalid, or mismatching redirection URI,
* ... the authorization server SHOULD inform the resource owner of the error and
* MUST NOT automatically redirect the user-agent to the invalid redirection URI."
* This implies 'invalid_request'.
*/ const resolvedRedirectUri = validateRedirectUri(client, redirect_uri);
request.query.redirect_uri = resolvedRedirectUri;
// 2. Validate scope (optional, but good practice)
/**
* RFC 6749, Section 4.1.2.1 Error Response
* "invalid_scope: The requested scope is invalid, unknown, or malformed."
*/ const body = parseRequestBody(request);
const requestedScope = scope || '';
const validatedScope = validateScope(requestedScope, config.predefinedScopes, client);
// 3. Authenticate resource owner and obtain consent
/**
* RFC 6749, Section 4.1.2.1 Error Response
* "access_denied: The resource owner or authorization server denied the request."
*/ const userId = body?.userId || request.query.userId; // Assuming userId is passed in the body or query for testing
if (!userId) {
throw new AccessDeniedError('Resource owner authentication and consent required');
}
// 4. Generate authorization code
/**
* RFC 6749, Section 4.1.2 Authorization Response
* "code: REQUIRED. The authorization code generated by the authorization server.
* The authorization code MUST expire shortly after it is issued to mitigate the risk of leaks.
* A maximum authorization code lifetime of 10 minutes is RECOMMENDED."
*/ const code = generateRandomString(32);
const expiresAt = new Date(Date.now() + (options?.authorizationCodeLifetime || 600) * 1000);
const authorizationCode = {
code,
expiresAt,
redirectUri: request.query.redirect_uri,
scope: validatedScope,
clientId: client.id,
userId: userId,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method
};
await storage.saveAuthorizationCode(authorizationCode);
// 5. Redirect user-agent back to client
/**
* RFC 6749, Section 4.1.2 Authorization Response
* "If the resource owner grants the access request, the authorization server
* issues an authorization code and delivers it to the client by adding the
* following parameters to the query component of the redirection URI..."
*/ const redirectUrl = new URL(request.query.redirect_uri);
redirectUrl.searchParams.append('code', code);
if (state) {
redirectUrl.searchParams.append('state', state);
}
return {
statusCode: 302,
headers: {
Location: redirectUrl.toString()
},
body: {},
cookies: {}
};
},
handleToken: async (context)=>{
const { request, storage, client } = context;
/**
* RFC 6749, Section 3.2.1 Client Authentication
* "Confidential clients or other clients issued client credentials MUST
* authenticate with the authorization server as described in Section 2.3
* when making requests to the token endpoint."
* RFC 6749, Section 5.2 Error Response
* "unauthorized_client: The client is not authorized to request an access token
* using this method."
*/ if (!client) {
throw new UnauthorizedClientError('Client not authenticated for authorization_code grant');
}
const body = parseRequestBody(request);
const { code, redirect_uri, code_verifier } = {
...request.query,
...body
};
/**
* RFC 6749, Section 4.1.3 Access Token Request
* "code: REQUIRED. The authorization code received from the authorization server."
* RFC 6749, Section 5.2 Error Response
* "invalid_request: The request is missing a required parameter."
*/ if (!code) {
throw new InvalidRequestError('Missing authorization code');
}
/**
* RFC 6749, Section 4.1.3 Access Token Request
* "redirect_uri: REQUIRED, if the "redirect_uri" parameter was included in the
* authorization request as described in Section 4.1.1, and their values MUST be identical."
* RFC 6749, Section 5.2 Error Response
* "invalid_request: The request is missing a required parameter."
*/ if (!redirect_uri) {
throw new InvalidRequestError('Missing redirect_uri');
}
/**
* RFC 7636, Section 4.5 Client Sends the Authorization Code and the Code Verifier to the Token Endpoint
* "code_verifier: REQUIRED. Code verifier"
* RFC 6749, Section 5.2 Error Response
* "invalid_request: The request is missing a required parameter."
*/ if (!code_verifier) {
throw new InvalidRequestError('Missing code_verifier');
}
// Validate code verifier format and length
validateCodeVerifier(code_verifier, options?.codeVerifierMinLength);
const authCode = await storage.getAuthorizationCode(code);
/**
* RFC 6749, Section 4.1.4 Access Token Response
* "If the request client authentication failed or is invalid, the authorization server returns
* an error response as described in Section 5.2."
* RFC 6749, Section 5.2 Error Response
* "invalid_grant: The provided authorization grant (e.g., authorization code, refresh token)
* or the refresh token is invalid, expired, revoked, does not match the redirection URI used
* in the authorization request, or was issued to another client."
*/ if (!authCode || authCode.clientId !== client.id || authCode.redirectUri !== redirect_uri) {
throw new InvalidGrantError('Invalid authorization code');
}
/**
* RFC 6749, Section 4.1.2 Authorization Response
* "The authorization code MUST expire shortly after it is issued to mitigate the risk of leaks."
* RFC 6749, Section 5.2 Error Response
* "invalid_grant: The provided authorization grant (e.g., authorization code, refresh token)
* or the refresh token is invalid, expired, revoked..."
*/ if (authCode.expiresAt < new Date()) {
await storage.deleteAuthorizationCode(code);
throw new InvalidGrantError('Authorization code expired');
}
// PKCE validation
/**
* RFC 7636, Section 4.6 Server Verifies code_verifier before Returning the Tokens
* "If the values are not equal, an error response indicating "invalid_grant" as described in
* Section 5.2 of [RFC6749] MUST be returned."
* RFC 7636, Section 4.4.1 Error Response
* "If the server supporting PKCE does not support the requested transformation, the authorization
* endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
*/ if (authCode.codeChallengeMethod === 'plain') {
if (authCode.codeChallenge !== code_verifier) {
throw new InvalidGrantError('Invalid code_verifier');
}
} else if (authCode.codeChallengeMethod === 'S256') {
const hashedCodeVerifier = createS256Challenge(code_verifier);
if (authCode.codeChallenge !== hashedCodeVerifier) {
throw new InvalidGrantError('Invalid code_verifier');
}
} else {
throw new InvalidRequestError('Unsupported code_challenge_method');
}
/**
* RFC 6749, Section 4.1.2 Authorization Response
* "The client MUST NOT use the authorization code more than once."
*/ await storage.deleteAuthorizationCode(code); // Authorization code must be used only once
// Generate access token using the configured token strategy
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server issues an access token and optional refresh token."
*/ const tokenParams = {
client,
userId: authCode.userId,
scope: authCode.scope,
metadata: {
grant_type: 'authorization_code',
authorization_code: code
}
};
const tokenStrategy = context.config.tokenStrategy; // Non-null assertion since we ensure it exists in createServer
const token = tokenStrategy.generateTokenPair ? await tokenStrategy.generateTokenPair(tokenParams, context) : await generateTokenPair(tokenStrategy, tokenParams, context);
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server MUST include the following parameters in the response:
* access_token, token_type, expires_in."
* "refresh_token: OPTIONAL."
* "scope: OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED."
*/ return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
access_token: token.accessToken,
token_type: 'Bearer',
expires_in: Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000),
refresh_token: token.refreshToken,
scope: token.scope
},
cookies: {}
};
}
};
}
/**
* Implements the Client Credentials Grant flow.
* This grant type is used by confidential clients to obtain an access token
* directly, without involving a resource owner.
* @returns A Grant object for the client_credentials type.
*/ function clientCredentialsGrant() {
return {
type: 'client_credentials',
/**
* Handles the client credentials request.
* @param context The context object containing the request, storage, and authenticated client.
* @returns A Promise that resolves to an OAuth2Response containing the access token.
* @throws {UnauthorizedClientError} If the client is not authenticated.
*/ handleToken: async (context)=>{
const { request, storage, client, config } = context;
/**
* RFC 6749, Section 4.4 Client Credentials Grant
* "The client credentials grant type MUST only be used by confidential clients."
* RFC 6749, Section 5.2 Error Response
* "unauthorized_client: The client is not authorized to request an access token
* using this method."
*/ if (!client) {
throw new UnauthorizedClientError('Client not authenticated for client_credentials grant');
}
// Validate scope (optional, but good practice)
/**
* RFC 6749, Section 3.3 Access Token Scope
* "If the client omits the scope parameter when requesting authorization, the authorization
* server MUST either process the request using a pre-defined default value or fail the request
* indicating an invalid scope."
* RFC 6749, Section 5.2 Error Response
* "invalid_scope: The requested scope is invalid, unknown, or malformed."
*/ const body = parseRequestBody(request);
const scope = body.scope || request.query.scope || '';
const validatedScope = validateScope(scope, config.predefinedScopes, client);
// Generate access token using the configured token strategy
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server issues an access token and optional refresh token."
*/ const tokenParams = {
client,
userId: client.id,
scope: validatedScope,
metadata: {
grant_type: 'client_credentials'
}
};
const issueRefreshToken = validatedScope.includes('offline_access');
let token;
const tokenStrategy = context.config.tokenStrategy; // Non-null assertion since we ensure it exists in createServer
if (issueRefreshToken) {
token = tokenStrategy.generateTokenPair ? await tokenStrategy.generateTokenPair(tokenParams, context) : await generateTokenPair(tokenStrategy, tokenParams, context);
} else {
// Only generate access token
token = await tokenStrategy.generateAccessToken(tokenParams, context);
}
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server MUST include the following parameters in the response:
* access_token, token_type, expires_in."
* "refresh_token: OPTIONAL."
* "scope: OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED."
*/ const responseBody = {
access_token: token.accessToken,
token_type: 'Bearer',
expires_in: Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000),
scope: token.scope
};
if (token.refreshToken) {
responseBody.refresh_token = token.refreshToken;
}
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: responseBody,
cookies: {}
};
}
};
}
/**
* Implements the Refresh Token Grant flow.
* This grant type is used to obtain new access tokens (and optionally new refresh tokens)
* using a refresh token, without requiring the user to re-authenticate.
* @returns A Grant object for the refresh_token type.
*/ function refreshTokenGrant() {
return {
type: 'refresh_token',
/**
* Handles the refresh token request.
* @param context The context object containing the request, storage, and authenticated client.
* @returns A Promise that resolves to an OAuth2Response containing the new access token and refresh token.
* @throws {UnauthorizedClientError} If the client is not authenticated.
* @throws {InvalidRequestError} If the refresh_token parameter is missing.
* @throws {InvalidGrantError} If the refresh token is invalid or expired.
*/ handleToken: async (context)=>{
const { request, storage, client, config } = context;
/**
* RFC 6749, Section 3.2.1 Client Authentication
* "Confidential clients or other clients issued client credentials MUST
* authenticate with the authorization server as described in Section 2.3
* when making requests to the token endpoint."
* RFC 6749, Section 5.2 Error Response
* "unauthorized_client: The client is not authorized to request an access token
* using this method."
*/ if (!client) {
throw new UnauthorizedClientError('Client not authenticated for refresh_token grant');
}
const body = parseRequestBody(request);
const { refresh_token, scope } = body;
/**
* RFC 6749, Section 6 Refreshing an Access Token
* "The client makes a request to the token endpoint by sending the
* following parameters... refresh_token: REQUIRED."
* RFC 6749, Section 5.2 Error Response
* "invalid_request: The request is missing a required parameter."
*/ if (!refresh_token) {
throw new InvalidRequestError('Missing refresh_token parameter');
}
const existingToken = await storage.getRefreshToken(refresh_token);
/**
* RFC 6749, Section 5.2 Error Response
* "invalid_grant: The provided authorization grant (e.g., authorization code, refresh token)
* or the refresh token is invalid, expired, revoked, does not match the redirection URI used
* in the authorization request, or was issued to another client."
*/ if (!existingToken || existingToken.clientId !== client.id) {
throw new InvalidGrantError('Invalid refresh token');
}
/**
* RFC 6749, Section 6 Refreshing an Access Token
* "If the refresh token is expired or invalid, the authorization server MUST
* return an error response as described in Section 5.2."
* RFC 6749, Section 5.2 Error Response
* "invalid_grant: The provided authorization grant (e.g., authorization code, refresh token)
* or the refresh token is invalid, expired, revoked..."
*/ if (existingToken.refreshToken && existingToken.refreshTokenExpiresAt && existingToken.refreshTokenExpiresAt < new Date()) {
await storage.revokeToken(existingToken.refreshToken); // Revoke expired token
throw new InvalidGrantError('Refresh token expired');
}
// Validate scope (optional, but good practice)
/**
* RFC 6749, Section 3.3 Access Token Scope
* "If the client omits the scope parameter when requesting authorization, the authorization
* server MUST either process the request using a pre-defined default value or fail the request
* indicating an invalid scope."
* RFC 6749, Section 5.2 Error Response
* "invalid_scope: The requested scope is invalid, unknown, or malformed."
*/ const requestedScope = scope || existingToken.scope;
const validatedScope = validateScope(requestedScope, config.predefinedScopes, client);
// Revoke the old refresh token (optional, but good practice for single-use refresh tokens)
/**
* RFC 6749, Section 6 Refreshing an Access Token
* "The authorization server MAY issue a new refresh token, in which case it MUST
* revoke the old refresh token."
*/ await storage.revokeToken(existingToken.refreshToken);
// Generate new access token and refresh token using the configured token strategy
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server issues an access token and optional refresh token."
*/ const tokenParams = {
client,
userId: existingToken.userId,
scope: validatedScope,
metadata: {
grant_type: 'refresh_token',
original_token: existingToken
}
};
const tokenStrategy = context.config.tokenStrategy; // Non-null assertion since we ensure it exists in createServer
const newToken = tokenStrategy.generateTokenPair ? await tokenStrategy.generateTokenPair(tokenParams, context) : await generateTokenPair(tokenStrategy, tokenParams, context);
/**
* RFC 6749, Section 5.1 Successful Response
* "The authorization server MUST include the following parameters in the response:
* access_token, token_type, expires_in."
* "refresh_token: OPTIONAL."
* "scope: OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED."
*/ return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
access_token: newToken.accessToken,
token_type: 'Bearer',
expires_in: Math.floor((newToken.accessTokenExpiresAt.getTime() - Date.now()) / 1000),
refresh_token: newToken.refreshToken,
scope: newToken.scope
},
cookies: {}
};
}
};
}
/**
* Implements the Password Grant flow.
* This grant type is used by resource owners to obtain an access token by providing their username and password.
* Note: This grant type is not recommended for public clients and should only be used by trusted clients.
* @returns A Grant object for the password type.
*/ function passwordGrant() {
return {
type: 'password',
handleToken: async (context)=>{
const { request, storage, client, config } = context;
const { username, password, scope } = parseRequestBody(request);
/**
* Ensure that we have an authenticated client.
*/ if (!client) {
throw new UnauthorizedClientError('Client not authenticated for password grant');
}
/**
* Scope validation is optional but recommended.
*/ const validatedScope = validateScope(scope, config.predefinedScopes, client);
/**
* Validate username and password.
*/ if (!username || !password) {
throw new InvalidRequestError('Missing username or password');
}
// Authenticate user
const user = await storage.getUserByCredentials(username, password);
if (!user) {
throw new InvalidGrantError('Invalid username or password');
}
// Generate access token using the configured token strategy
const tokenParams = {
client,
userId: user.id,
scope: validatedScope,
metadata: {
grant_type: 'password',
username: username
}
};
const tokenStrategy = context.config.tokenStrategy; // Non-null assertion since we ensure it exists in createServer
const token = await tokenStrategy.generateAccessToken(tokenParams, context);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
access_token: token.accessToken,
token_type: 'Bearer',
expires_in: Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000),
scope: token.scope
},
cookies: {}
};
}
};
}
export { authorizationCodeGrant, clientCredentialsGrant, passwordGrant, refreshTokenGrant };