diff --git a/src/server/auth/crypto.ts b/src/server/auth/crypto.ts new file mode 100644 index 00000000..3946d1f8 --- /dev/null +++ b/src/server/auth/crypto.ts @@ -0,0 +1,5 @@ +import crypto from "node:crypto"; + +export function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index db1da2db..be3ac49a 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -1,20 +1,19 @@ import { RequestHandler } from "express"; import { z } from "zod"; -import { OAuthRegisteredClientsStore } from "../clients.js"; import { isValidUrl } from "../validation.js"; +import { OAuthServerProvider } from "../provider.js"; export type AuthorizationHandlerOptions = { - /** - * A store used to read information about registered OAuth clients. - */ - store: OAuthRegisteredClientsStore; + provider: OAuthServerProvider; }; +// Parameters that must be validated in order to issue redirects. const ClientAuthorizationParamsSchema = z.object({ client_id: z.string(), redirect_uri: z.string().optional().refine((value) => value === undefined || isValidUrl(value), { message: "redirect_uri must be a valid URL" }), }); +// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. const RequestAuthorizationParamsSchema = z.object({ response_type: z.literal("code"), code_challenge: z.string(), @@ -23,7 +22,7 @@ const RequestAuthorizationParamsSchema = z.object({ state: z.string().optional(), }); -export function authorizationHandler({ store }: AuthorizationHandlerOptions): RequestHandler { +export function authorizationHandler({ provider }: AuthorizationHandlerOptions): RequestHandler { return async (req, res) => { if (req.method !== "GET" && req.method !== "POST") { res.status(405).end("Method Not Allowed"); @@ -38,7 +37,7 @@ export function authorizationHandler({ store }: AuthorizationHandlerOptions): Re return; } - const client = await store.getClient(client_id); + const client = await provider.clientsStore.getClient(client_id); if (!client) { res.status(400).end("Bad Request: invalid client_id"); return; @@ -67,8 +66,9 @@ export function authorizationHandler({ store }: AuthorizationHandlerOptions): Re return; } + let requestedScopes: string[] = []; if (params.scope !== undefined && client.scope !== undefined) { - const requestedScopes = params.scope.split(" "); + requestedScopes = params.scope.split(" "); const allowedScopes = new Set(client.scope.split(" ")); // If any requested scope is not in the client's registered scopes, error out @@ -83,8 +83,12 @@ export function authorizationHandler({ store }: AuthorizationHandlerOptions): Re } } - // TODO: Store code challenge - // TODO: Generate authorization code - // TODO: Redirect to redirect_uri (handle in calling code) + await provider.authorize({ + client, + state: params.state, + scopes: requestedScopes, + redirectUri: redirect_uri, + codeChallenge: params.code_challenge, + }, res); }; } \ No newline at end of file diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index bd5b080a..25946e34 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -8,7 +8,7 @@ export type ClientRegistrationHandlerOptions = { /** * A store used to save information about dynamically registered OAuth clients. */ - store: OAuthRegisteredClientsStore; + clientsStore: OAuthRegisteredClientsStore; /** * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). @@ -20,8 +20,8 @@ export type ClientRegistrationHandlerOptions = { const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days -export function clientRegistrationHandler({ store, clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS }: ClientRegistrationHandlerOptions): RequestHandler { - if (!store.registerClient) { +export function clientRegistrationHandler({ clientsStore, clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS }: ClientRegistrationHandlerOptions): RequestHandler { + if (!clientsStore.registerClient) { throw new Error("Client registration store does not support registering clients"); } @@ -55,7 +55,7 @@ export function clientRegistrationHandler({ store, clientSecretExpirySeconds = D client_secret_expires_at: clientSecretExpirySeconds > 0 ? clientIdIssuedAt + clientSecretExpirySeconds : 0 }; - clientInfo = await store.registerClient!(clientInfo); + clientInfo = await clientsStore.registerClient!(clientInfo); return clientInfo; } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts new file mode 100644 index 00000000..37a7baf7 --- /dev/null +++ b/src/server/auth/provider.ts @@ -0,0 +1,32 @@ +import { Response } from "express"; +import { OAuthRegisteredClientsStore } from "./clients.js"; +import { OAuthClientInformationFull } from "../../shared/auth.js"; + +export type AuthorizationParams = { + client: OAuthClientInformationFull; + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; +}; + +/** + * Implements an end-to-end OAuth server. + */ +export interface OAuthServerProvider { + /** + * A store used to read information about registered OAuth clients. + */ + get clientsStore(): OAuthRegisteredClientsStore; + + /** + * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. + * + * An authorization code can be generated using the `generateToken` function. + * + * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: + * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. + * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. + */ + authorize(params: AuthorizationParams, res: Response): Promise; +} \ No newline at end of file