diff --git a/apps/workspace/.env.example b/apps/workspace/.env.example index cabb330..bee9011 100644 --- a/apps/workspace/.env.example +++ b/apps/workspace/.env.example @@ -20,6 +20,9 @@ MAX_BLOB_BYTES=536870912 # Shared secret for POST /api/auth/discord/bot-login. Use at least 32 random characters. # DISCORD_BOT_API_KEY=replace-with-a-long-random-secret +# Register external OAuth-style clients. A JSON object with a `clients` array. +# OAUTH_CLIENTS_JSON={"clients":[{"clientId":"...","clientSecret":"...","redirectUris":["https://client.example/callback"],"scopes":["identity"]}]} + # Optional override for the built-in 90-day browser runtime development license. # Required for deployments whose browser host is not localhost. VITE_UNIVER_LICENSE= diff --git a/apps/workspace/README.md b/apps/workspace/README.md index 8d83bbe..311b426 100644 --- a/apps/workspace/README.md +++ b/apps/workspace/README.md @@ -118,6 +118,17 @@ shared key to a Discord client or browser. If the Bot initially supplies only OAuth login fills those placeholders from the verified Discord profile without replacing profile fields that the User has already customized. +Workspace exposes a generic OAuth-style authorization capability. A registered +external client starts `GET /api/auth/authorize`; the authorize endpoint reuses +`workspace_session`, redirecting through the existing login page only when the +session is absent, then returns a one-time short-lived code to the registered +redirect URI. `POST /api/auth/token` validates the client secret, the registered +redirect URI, the PKCE verifier, expiry, and one-time use before returning the +Workspace identity. Registration is deployment-supplied via `OAUTH_CLIENTS_JSON`. +Existing Workspace login, OAuth callbacks, Cookie behavior, and product APIs +remain unchanged. The capability is additive and does not add a proxy or +deployment component. + The browser uses the same built-in runtime development license as Workspace CLI. Both copies are rotated every 90 days and are application credentials, not the repository software license. The built-in credential is for `localhost`; @@ -155,6 +166,7 @@ docker run --name univer-workspace \ -e DISCORD_CLIENT_ID \ -e DISCORD_CLIENT_SECRET \ -e DISCORD_CALLBACK_URL=https://workspace.univer.plus/api/auth/discord/callback \ + -e OAUTH_CLIENTS_JSON \ -e SECURE_COOKIES=true \ univer-workspace ``` diff --git a/apps/workspace/contracts/http/openapi.yaml b/apps/workspace/contracts/http/openapi.yaml index 8929cfa..0947970 100644 --- a/apps/workspace/contracts/http/openapi.yaml +++ b/apps/workspace/contracts/http/openapi.yaml @@ -42,6 +42,10 @@ security: paths: /api/session: $ref: ./paths/auth.yaml#/~1api~1session + /api/auth/authorize: + $ref: ./paths/auth.yaml#/~1api~1auth~1authorize + /api/auth/token: + $ref: ./paths/auth.yaml#/~1api~1auth~1token /api/auth/logout: $ref: ./paths/auth.yaml#/~1api~1auth~1logout /api/auth/password/register: diff --git a/apps/workspace/contracts/http/paths/auth.yaml b/apps/workspace/contracts/http/paths/auth.yaml index c1302d0..092142b 100644 --- a/apps/workspace/contracts/http/paths/auth.yaml +++ b/apps/workspace/contracts/http/paths/auth.yaml @@ -14,6 +14,104 @@ schema: $ref: ../schemas/identity.yaml#/SessionView +/api/auth/authorize: + get: + tags: [Authentication] + operationId: oauthAuthorize + summary: Start an OAuth-style authorization for a registered client. + security: + - {} + - sessionCookie: [] + + parameters: + - name: client_id + in: query + required: true + schema: + type: string + - name: redirect_uri + in: query + required: true + schema: + type: string + - name: state + in: query + required: true + schema: + type: string + pattern: ^[A-Za-z0-9_-]{32,256}$ + - name: code_challenge + in: query + required: true + schema: + type: string + - name: scope + in: query + required: false + schema: + type: string + responses: + "302": + description: Redirect to Workspace login or back to the registered redirect_uri with a one-time code. + headers: + Location: + required: true + schema: + type: string + "400": + $ref: ../schemas/common.yaml#/BadRequest + +/api/auth/token: + post: + tags: [Authentication] + operationId: oauthToken + summary: Exchange a one-time authorization code for a registered client identity. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, client_id, client_secret, redirect_uri, code_verifier] + properties: + grant_type: + type: string + code: + type: string + client_id: + type: string + client_secret: + type: string + redirect_uri: + type: string + code_verifier: + type: string + responses: + "200": + description: The registered client identity. + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + user: + $ref: ../schemas/identity.yaml#/User + "400": + $ref: ../schemas/common.yaml#/BadRequest + "401": + description: Invalid client secret or PKCE verifier. + content: + application/json: + schema: + $ref: ../schemas/common.yaml#/ErrorResponse + /api/auth/logout: post: tags: [Session] diff --git a/apps/workspace/docs/architecture.md b/apps/workspace/docs/architecture.md index dc990a9..eaa9f50 100644 --- a/apps/workspace/docs/architecture.md +++ b/apps/workspace/docs/architecture.md @@ -164,7 +164,12 @@ Express Request/Response 和 Univer SDK class 不进入业务 Module 的公开 I Univer 集中在 `integrations/univer`,向业务 Module 提供产品语义的 Interface,不对 SDK 方法做一一对应的空壳封装。外部 OAuth Provider 位于 Identity Module,并通过 -`GitHubOAuthProvider` / `DiscordOAuthProvider` Interface 在测试中替换。 +`GitHubOAuthProvider` / `DiscordOAuthProvider` Interface 在测试中替换。Identity Router +为部署注册的 OAuth client 提供通用 authorize/token 交接:authorize 复用 +`workspace_session`,未登录时回到现有登录流程;token 只兑换一次性、短期、绑定 PKCE +和已注册 redirect URI 的 code。Workspace Session 仍是唯一的身份权威来源,现有登录、 +Cookie、OAuth callback 和产品 API 保持原有行为;外部 client 只通过通用 OAuth 协议 +接入,代码不感知其业务身份。 跨产品数据库和 Collaboration Service 的写入由 `operations` Module 持久化和恢复,不用 一次 SQLite transaction 假装覆盖两个系统。 diff --git a/apps/workspace/generated/http/openapi.bundled.yaml b/apps/workspace/generated/http/openapi.bundled.yaml index 909db0a..2c8c587 100644 --- a/apps/workspace/generated/http/openapi.bundled.yaml +++ b/apps/workspace/generated/http/openapi.bundled.yaml @@ -56,6 +56,108 @@ paths: application/json: schema: $ref: '#/components/schemas/SessionView' + /api/auth/authorize: + get: + tags: + - Authentication + operationId: oauthAuthorize + summary: Start an OAuth-style authorization for a registered client. + security: + - {} + - sessionCookie: [] + parameters: + - name: client_id + in: query + required: true + schema: + type: string + - name: redirect_uri + in: query + required: true + schema: + type: string + - name: state + in: query + required: true + schema: + type: string + pattern: ^[A-Za-z0-9_-]{32,256}$ + - name: code_challenge + in: query + required: true + schema: + type: string + - name: scope + in: query + required: false + schema: + type: string + responses: + '302': + description: Redirect to Workspace login or back to the registered redirect_uri with a one-time code. + headers: + Location: + required: true + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + /api/auth/token: + post: + tags: + - Authentication + operationId: oauthToken + summary: Exchange a one-time authorization code for a registered client identity. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - code + - client_id + - client_secret + - redirect_uri + - code_verifier + properties: + grant_type: + type: string + code: + type: string + client_id: + type: string + client_secret: + type: string + redirect_uri: + type: string + code_verifier: + type: string + responses: + '200': + description: The registered client identity. + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + user: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + description: Invalid client secret or PKCE verifier. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/auth/logout: post: tags: diff --git a/apps/workspace/generated/http/schema.d.ts b/apps/workspace/generated/http/schema.d.ts index 0f94c35..989dbd5 100644 --- a/apps/workspace/generated/http/schema.d.ts +++ b/apps/workspace/generated/http/schema.d.ts @@ -21,6 +21,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/authorize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Start an OAuth-style authorization for a registered client. */ + get: operations["oauthAuthorize"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Exchange a one-time authorization code for a registered client identity. */ + post: operations["oauthToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/logout": { parameters: { query?: never; @@ -1867,6 +1901,78 @@ export interface operations { }; }; }; + oauthAuthorize: { + parameters: { + query: { + client_id: string; + redirect_uri: string; + state: string; + code_challenge: string; + scope?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to Workspace login or back to the registered redirect_uri with a one-time code. */ + 302: { + headers: { + Location: string; + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + }; + }; + oauthToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + grant_type?: string; + code: string; + client_id: string; + client_secret: string; + redirect_uri: string; + code_verifier: string; + }; + }; + }; + responses: { + /** @description The registered client identity. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + access_token?: string; + token_type?: string; + expires_in?: number; + user?: components["schemas"]["User"]; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description Invalid client secret or PKCE verifier. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; logout: { parameters: { query?: never; diff --git a/apps/workspace/server/src/app.ts b/apps/workspace/server/src/app.ts index 670c891..f21d249 100644 --- a/apps/workspace/server/src/app.ts +++ b/apps/workspace/server/src/app.ts @@ -45,8 +45,10 @@ import { createIdentityRouter, createGitHubOAuthProvider, createDiscordOAuthProvider, + createOAuthAuthorizationRouter, type DiscordOAuthProvider, type GitHubOAuthProvider, + type IssuedAuthorization, IdentityRepository, type IdentityModule, } from "./modules/identity/index.js"; @@ -288,6 +290,17 @@ export function createWorkspaceApplication( : {}), }) ); + const oauthClients = config.oauthClients; + const authorizationStore = new Map(); + app.use( + "/api/auth", + createOAuthAuthorizationRouter({ + identity, + secureCookies: config.secureCookies, + oauthClients, + authorizationStore, + }) + ); app.use("/api", createSpacesRouter({ identity, spaces })); app.use("/api", createNodesRouter({ identity, nodes })); app.use("/api", createResourcesRouter({ identity, resources })); diff --git a/apps/workspace/server/src/config.ts b/apps/workspace/server/src/config.ts index f277b8e..66f6f0b 100644 --- a/apps/workspace/server/src/config.ts +++ b/apps/workspace/server/src/config.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import type { OAuthClientConfig } from "./modules/identity/oauth-clients.js"; export interface WorkspaceConfig { readonly host: string; @@ -20,6 +21,7 @@ export interface WorkspaceConfig { readonly clientSecret: string; readonly callbackUrl: string; } | null; + readonly oauthClients: OAuthClientConfig | null; } export function loadConfig( @@ -27,6 +29,7 @@ export function loadConfig( ): WorkspaceConfig { const githubOAuth = githubConfig(environment); const discordOAuth = discordConfig(environment); + const oauthClientsConfig = oauthClientConfig(environment); const discordBotApiKey = optionalSecret( environment.DISCORD_BOT_API_KEY, "DISCORD_BOT_API_KEY" @@ -62,6 +65,7 @@ export function loadConfig( ...(discordBotApiKey ? { discordBotApiKey } : {}), githubOAuth, discordOAuth, + oauthClients: oauthClientsConfig, }; } @@ -76,6 +80,59 @@ function optionalSecret( return value; } +function oauthClientConfig( + environment: NodeJS.ProcessEnv +): { + readonly clients: readonly { + readonly clientId: string; + readonly clientSecret: string; + readonly redirectUris: readonly string[]; + readonly scopes: readonly string[]; + }[]; +} | null { + const raw = environment.OAUTH_CLIENTS_JSON; + if (raw === undefined || raw === "") return null; + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object" || !Array.isArray((parsed as { clients?: unknown }).clients)) { + throw new Error("OAUTH_CLIENTS_JSON must be an object with a clients array"); + } + const clients = (parsed as { readonly clients: readonly unknown[] }).clients; + return { + clients: clients.map((value) => { + const rawClient = value as Record; + const clientId = requireString(rawClient.clientId, "clientId"); + const clientSecret = requireString(rawClient.clientSecret, "clientSecret"); + const redirectUris = convertStringArray(rawClient.redirectUris, "redirectUris"); + if (redirectUris.length === 0) { + throw new Error("OAUTH_CLIENTS_JSON client redirectUris must be a non-empty array"); + } + const scopes = convertStringArray(rawClient.scopes, "scopes"); + if (scopes.length === 0) { + throw new Error("OAUTH_CLIENTS_JSON client scopes must be a non-empty array"); + } + return { + clientId, + clientSecret, + redirectUris, + scopes, + }; + }), + }; +} + +function requireString(value: unknown, name: string): string { + if (typeof value !== "string" || value === "") { + throw new Error(`OAUTH_CLIENTS_JSON ${name} must be a non-empty string`); + } + return value; +} + +function convertStringArray(value: unknown, name: string): string[] { + if (!Array.isArray(value)) return []; + for (const item of value) requireString(item, name); + return value.map((item) => item as string); +} + function discordConfig( environment: NodeJS.ProcessEnv ): { diff --git a/apps/workspace/server/src/middleware/errors.ts b/apps/workspace/server/src/middleware/errors.ts index 1f8d63a..b332e83 100644 --- a/apps/workspace/server/src/middleware/errors.ts +++ b/apps/workspace/server/src/middleware/errors.ts @@ -20,6 +20,12 @@ export type ApplicationErrorCode = | "DISCORD_OAUTH_UNAVAILABLE" | "DISCORD_OAUTH_FAILED" | "DISCORD_BOT_AUTH_UNAVAILABLE" + | "OAUTH_CLIENT_UNAVAILABLE" + | "INVALID_CLIENT_SECRET" + | "INVALID_REDIRECT_URI" + | "INVALID_GRANT" + | "INVALID_STATE" + | "INVALID_CODE_VERIFIER" | "CLI_AUTHORIZATION_INVALID" | "CLI_AUTHORIZATION_EXPIRED" | "CLI_AUTHORIZATION_UNAVAILABLE"; diff --git a/apps/workspace/server/src/modules/identity/index.ts b/apps/workspace/server/src/modules/identity/index.ts index 75c72e3..0a9ea36 100644 --- a/apps/workspace/server/src/modules/identity/index.ts +++ b/apps/workspace/server/src/modules/identity/index.ts @@ -9,6 +9,11 @@ export { IdentityRepository } from "./identity.repository.js"; export { createIdentityRouter } from "./identity.router.js"; export { createGitHubOAuthProvider } from "./github-oauth.js"; export { createDiscordOAuthProvider } from "./discord-oauth.js"; +export { createOAuthAuthorizationRouter } from "./oauth-authorization-router.js"; +export type { + IssuedAuthorization, + OAuthClientConfig, +} from "./oauth-clients.js"; export type { DiscordOAuthProvider, GitHubOAuthProvider, diff --git a/apps/workspace/server/src/modules/identity/oauth-authorization-router.ts b/apps/workspace/server/src/modules/identity/oauth-authorization-router.ts new file mode 100644 index 0000000..023a467 --- /dev/null +++ b/apps/workspace/server/src/modules/identity/oauth-authorization-router.ts @@ -0,0 +1,148 @@ +import { json, Router } from "express"; +import { ApplicationError } from "../../middleware/errors.js"; +import type { IdentityModule } from "./identity.service.js"; +import { + authorizeRedirectTarget, + defaultOAuthScope, + issueOAuthAuthorizationCode, + requireOAuthAuthorization, + requireOAuthState, + validateOAuthClientSecret, + validateRegisteredRedirectUri, + type IssuedAuthorization, + type OAuthClient, + type OAuthClientConfig, +} from "./oauth-clients.js"; + +export function createOAuthAuthorizationRouter(options: { + readonly identity: IdentityModule; + readonly secureCookies: boolean; + readonly oauthClients: OAuthClientConfig | null; + readonly authorizationStore: Map; +}): Router { + const router = Router(); + router.use(json({ limit: "1mb" })); + const clients = new Map(); + for (const client of options.oauthClients?.clients ?? []) { + clients.set(client.clientId, client); + } + + router.get("/authorize", (request, response) => { + const client = requireRegisteredClient(clients, request.query.client_id); + const redirectUri = validateRegisteredRedirectUri( + request.query.redirect_uri, + client.redirectUris + ); + const state = requireOAuthState(request.query.state); + const codeChallenge = requireQueryString(request.query.code_challenge); + const scope = requireScope(request.query.scope, client); + + const session = options.identity.getSession(request.headers.cookie); + if (!session.authenticated) { + const returnTo = `/api/auth/authorize?client_id=${encodeURIComponent(client.clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}&code_challenge=${encodeURIComponent(codeChallenge)}&scope=${encodeURIComponent(scope)}`; + response.redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); + return; + } + + const now = Date.now() + for (const [code, pending] of options.authorizationStore) { + if (pending.expiresAt <= now) options.authorizationStore.delete(code) + } + const authorization = issueOAuthAuthorizationCode( + client.clientId, + state, + codeChallenge, + redirectUri, + scope, + session.user, + now + ); + options.authorizationStore.set(authorization.code, authorization); + response.redirect( + authorizeRedirectTarget({ redirectUri, state, scope }, authorization.code) + ); + }); + + router.post("/token", (request, response) => { + const client = requireRegisteredClient(clients, request.body.client_id); + validateOAuthClientSecret(request.body.client_secret, client.clientSecret); + const redirectUri = validateRegisteredRedirectUri( + request.body.redirect_uri, + client.redirectUris + ); + const code = requireQueryString(request.body.code); + const codeVerifier = requireQueryString(request.body.code_verifier); + + const authorization = options.authorizationStore.get(code); + if (authorization === undefined) { + throw new ApplicationError( + "INVALID_GRANT", + 400, + "authorization code is invalid." + ); + } + const user = requireOAuthAuthorization(authorization, { + code, + clientId: client.clientId, + redirectUri, + codeVerifier, + now: Date.now(), + }); + options.authorizationStore.delete(code); + + response.json({ + access_token: "", + token_type: "Bearer", + expires_in: 3600, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }, + }); + }); + + return router; +} + +function requireRegisteredClient( + clients: ReadonlyMap, + raw: unknown +): OAuthClient { + const clientId = requireQueryString(raw); + const client = clients.get(clientId); + if (client === undefined) { + throw new ApplicationError( + "OAUTH_CLIENT_UNAVAILABLE", + 400, + "The requested OAuth client is not configured." + ); + } + return client; +} + +function requireScope(raw: unknown, client: OAuthClient): string { + const requested = typeof raw === "string" && raw !== "" ? raw : defaultOAuthScope(); + const requestedScopes = requested.split(" ").filter((value) => value !== ""); + if (requestedScopes.length === 0) { + throw new ApplicationError("INVALID_INPUT", 400, "scope must not be empty."); + } + for (const value of requestedScopes) { + if (!client.scopes.includes(value)) { + throw new ApplicationError( + "INVALID_INPUT", + 400, + `scope "${value}" is not permitted for this client.` + ); + } + } + return requested; +} + +function requireQueryString(value: unknown): string { + if (typeof value !== "string" || value === "") { + throw new ApplicationError("INVALID_INPUT", 400, "A required parameter is missing."); + } + return value; +} diff --git a/apps/workspace/server/src/modules/identity/oauth-clients.ts b/apps/workspace/server/src/modules/identity/oauth-clients.ts new file mode 100644 index 0000000..05ba667 --- /dev/null +++ b/apps/workspace/server/src/modules/identity/oauth-clients.ts @@ -0,0 +1,180 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { ApplicationError } from "../../middleware/errors.js"; +import type { User } from "./identity.types.js"; + +const CODE_TTL_MS = 60_000; +const STATE_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u; +const DEFAULT_SCOPES = ["identity"] as const; + +export interface OAuthClient { + readonly clientId: string; + readonly clientSecret: string; + readonly redirectUris: readonly string[]; + readonly scopes: readonly string[]; +} + +export interface OAuthClientConfig { + readonly clients: readonly OAuthClient[]; +} + +export interface IssuedAuthorization { + readonly code: string; + readonly codeChallenge: string; + readonly redirectUri: string; + readonly clientId: string; + readonly scope: string; + readonly user: User; + readonly expiresAt: number; +} + +export function requireOAuthState(value: unknown): string { + if (typeof value !== "string" || !STATE_PATTERN.test(value)) { + throw new ApplicationError( + "INVALID_INPUT", + 400, + "state must be a 32 to 256 character base64url value." + ); + } + return value; +} + +export function validateOAuthClientSecret( + provided: string | undefined, + expected: string +): void { + if (provided === undefined || !secretsMatch(provided, expected)) { + throw new ApplicationError( + "INVALID_CLIENT_SECRET", + 401, + "A valid client secret is required." + ); + } +} + +export function validateOAuthCodeVerifier( + provided: string | undefined, + expectedChallenge: string +): void { + if (provided === undefined || sha256Base64Url(provided) !== expectedChallenge) { + throw new ApplicationError( + "INVALID_CODE_VERIFIER", + 401, + "A valid PKCE code verifier is required." + ); + } +} + +export function validateRegisteredRedirectUri( + requested: unknown, + allowed: readonly string[] +): string { + if (typeof requested !== "string") { + throw new ApplicationError("INVALID_INPUT", 400, "redirect_uri is required."); + } + const parsed = new URL(requested); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new ApplicationError( + "INVALID_REDIRECT_URI", + 400, + "redirect_uri must use http or https." + ); + } + if (!allowed.includes(parsed.toString())) { + throw new ApplicationError( + "INVALID_REDIRECT_URI", + 400, + "redirect_uri is not registered for this client." + ); + } + return parsed.toString(); +} + +export function issueOAuthAuthorizationCode( + clientId: string, + state: string, + codeChallenge: string, + redirectUri: string, + scope: string, + user: User, + now: number = Date.now() +): IssuedAuthorization { + verifyState(state); + return { + code: randomBytes(24).toString("base64url"), + codeChallenge, + redirectUri, + clientId, + scope, + user, + expiresAt: now + CODE_TTL_MS, + }; +} + +export function requireOAuthAuthorization( + authorization: IssuedAuthorization, + input: { + readonly code: string; + readonly clientId: string; + readonly redirectUri: string; + readonly codeVerifier: string; + readonly now: number; + } +): User { + if (input.now >= authorization.expiresAt) { + throw new ApplicationError("INVALID_GRANT", 400, "authorization code expired."); + } + if (input.clientId !== authorization.clientId) { + throw new ApplicationError("INVALID_GRANT", 400, "client_id does not match."); + } + if (input.redirectUri !== authorization.redirectUri) { + throw new ApplicationError( + "INVALID_GRANT", + 400, + "redirect_uri does not match the authorization request." + ); + } + validateOAuthCodeVerifier(input.codeVerifier, authorization.codeChallenge); + if (input.code !== authorization.code) { + throw new ApplicationError("INVALID_GRANT", 400, "authorization code is invalid."); + } + return authorization.user; +} + +export function authorizeRedirectTarget( + input: { + readonly redirectUri: string; + readonly state: string; + readonly scope: string; + }, + code: string +): string { + const target = new URL(input.redirectUri); + target.searchParams.set("code", code); + target.searchParams.set("state", input.state); + target.searchParams.set("scope", input.scope); + return target.toString(); +} + +export function defaultOAuthScope(): string { + return DEFAULT_SCOPES.join(" "); +} + +function secretsMatch(actual: string, expected: string): boolean { + const actualHash = createHash("sha256").update(actual).digest(); + const expectedHash = createHash("sha256").update(expected).digest(); + return timingSafeEqual(actualHash, expectedHash); +} + +function sha256Base64Url(value: string): string { + return createHash("sha256").update(value).digest("base64url"); +} + +function verifyState(state: string): void { + if (!STATE_PATTERN.test(state)) { + throw new ApplicationError( + "INVALID_INPUT", + 400, + "state must be a 32 to 256 character base64url value." + ); + } +} diff --git a/apps/workspace/test/integration/oauth-authorization.test.ts b/apps/workspace/test/integration/oauth-authorization.test.ts new file mode 100644 index 0000000..2369084 --- /dev/null +++ b/apps/workspace/test/integration/oauth-authorization.test.ts @@ -0,0 +1,229 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createWorkspaceApplication, + type WorkspaceApplication, +} from "../../server/src/app.js"; +import { loadConfig, type WorkspaceConfig } from "../../server/src/config.js"; + +const CLIENT_ID = "internal-client"; +const CLIENT_SECRET = "test-client-secret-at-least-32-characters"; +const CALLBACK_URL = "https://client.example.test/auth/callback"; +const STATE = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH"; +const CODE_VERIFIER = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const CODE_CHALLENGE = createHash("sha256").update(CODE_VERIFIER).digest("base64url"); +const OAUTH_CLIENTS_JSON = JSON.stringify({ + clients: [ + { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUris: [CALLBACK_URL], + scopes: ["identity"], + }, + ], +}); + +let application: WorkspaceApplication | undefined; +let server: Server | undefined; +let directory: string | undefined; + +afterEach(async () => { + if (server?.listening) { + await new Promise((resolve, reject) => + server!.close((error) => (error ? reject(error) : resolve())) + ); + } + await application?.close(); + if (directory) rmSync(directory, { recursive: true, force: true }); + application = undefined; + server = undefined; + directory = undefined; +}); + +describe("OAuth authorization", () => { + it("loads registered clients from OAUTH_CLIENTS_JSON", () => { + const config = loadConfig({ OAUTH_CLIENTS_JSON }); + expect(config.oauthClients?.clients).toHaveLength(1); + expect(config.oauthClients?.clients[0]).toMatchObject({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUris: [CALLBACK_URL], + scopes: ["identity"], + }); + }); + + it("rejects OAUTH_CLIENTS_JSON without an array or with a missing client id", () => { + expect(() => loadConfig({ OAUTH_CLIENTS_JSON: "{}" })).toThrow(); + expect(() => + loadConfig({ + OAUTH_CLIENTS_JSON: JSON.stringify({ + clients: [{ clientSecret: CLIENT_SECRET, redirectUris: [CALLBACK_URL], scopes: ["identity"] }], + }), + }) + ).toThrow(); + }); + + it("returns through Workspace login before issuing a code", async () => { + const origin = await startApplication(); + const response = await fetch( + `${origin}/api/auth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${STATE}&code_challenge=${CODE_CHALLENGE}&scope=identity`, + { redirect: "manual" } + ); + + expect(response.status).toBe(302); + const location = response.headers.get("location"); + expect(location).toContain("/login?returnTo="); + expect(location).toContain(encodeURIComponent(STATE)); + }); + + it("issues a one-time short-lived code for an authenticated User and exchanges it", async () => { + const origin = await startApplication(); + const registration = await fetch(`${origin}/api/auth/password/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: "alice", + displayName: "Alice", + password: "correct horse battery staple", + }), + }); + const session = (await registration.json()) as { + readonly user: { readonly id: string }; + }; + const cookie = registration.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + + const authorize = await fetch( + `${origin}/api/auth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${STATE}&code_challenge=${CODE_CHALLENGE}&scope=identity`, + { headers: { cookie: cookie! }, redirect: "manual" } + ); + expect(authorize.status).toBe(302); + const callback = new URL(authorize.headers.get("location")!); + expect(`${callback.origin}${callback.pathname}`).toBe(CALLBACK_URL); + const code = callback.searchParams.get("code"); + expect(code).toBeTruthy(); + + const token = await fetch(`${origin}/api/auth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + redirect_uri: CALLBACK_URL, + code_verifier: CODE_VERIFIER, + }), + }); + expect(token.status).toBe(200); + const body = (await token.json()) as { + readonly user: { + readonly id: string; + readonly username: string; + readonly displayName: string; + }; + }; + expect(body.user).toMatchObject({ + id: session.user.id, + username: "alice", + displayName: "Alice", + }); + + const replay = await fetch(`${origin}/api/auth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + redirect_uri: CALLBACK_URL, + code_verifier: CODE_VERIFIER, + }), + }); + expect(replay.status).toBe(400); + expect(await replay.json()).toMatchObject({ + error: { code: "INVALID_GRANT" }, + }); + }); + + it("rejects an unknown client and an invalid client secret", async () => { + const origin = await startApplication(); + const unknown = await fetch( + `${origin}/api/auth/authorize?client_id=someone-else&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${STATE}&code_challenge=${CODE_CHALLENGE}&scope=identity`, + { redirect: "manual" } + ); + expect(unknown.status).toBe(400); + expect(await unknown.json()).toMatchObject({ + error: { code: "OAUTH_CLIENT_UNAVAILABLE" }, + }); + + const secret = await fetch(`${origin}/api/auth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "not-a-real-code", + client_id: CLIENT_ID, + client_secret: "wrong-secret", + redirect_uri: CALLBACK_URL, + code_verifier: CODE_VERIFIER, + }), + }); + expect(secret.status).toBe(401); + expect(await secret.json()).toMatchObject({ + error: { code: "INVALID_CLIENT_SECRET" }, + }); + }); + + it("rejects a state outside the base64url protocol", async () => { + const origin = await startApplication(); + const response = await fetch( + `${origin}/api/auth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=not-valid&code_challenge=${CODE_CHALLENGE}&scope=identity` + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "INVALID_INPUT" }, + }); + }); + + it("rejects a redirect_uri not registered for the client", async () => { + const origin = await startApplication(); + const response = await fetch( + `${origin}/api/auth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent("https://evil.example.test/callback")}&state=${STATE}&code_challenge=${CODE_CHALLENGE}&scope=identity`, + { redirect: "manual" } + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "INVALID_REDIRECT_URI" }, + }); + }); +}); + +async function startApplication(): Promise { + directory = mkdtempSync(join(tmpdir(), "univer-oauth-authorization-")); + application = createWorkspaceApplication({ + host: "127.0.0.1", + port: 3020, + databaseFilename: join(directory, "product.sqlite"), + collaborationDatabaseFilename: join(directory, "collaboration.sqlite"), + secureCookies: false, + sessionTtlMs: 60_000, + oauthClients: JSON.parse(OAUTH_CLIENTS_JSON) as WorkspaceConfig["oauthClients"], + }); + server = createServer(application.app); + await new Promise((resolve, reject) => { + server!.once("error", reject); + server!.listen(0, "127.0.0.1", () => { + server!.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server did not expose a TCP address"); + } + return `http://127.0.0.1:${address.port}`; +}