UNPKG

@oa2/core

Version:

A comprehensive, RFC-compliant OAuth 2.0 authorization server implementation in TypeScript

495 lines (492 loc) 25.5 kB
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 };