From 39618d7880a05ef3527a4a5c3ed245f0b1071817 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 18:53:23 -0400 Subject: [PATCH] feat: add oauth support --- README.md | 62 ++++++++- packages/core/README.md | 51 ++++++++ packages/core/src/handlers/oauthHandlers.ts | 134 ++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/tests/oauthHandlers.test.js | 119 +++++++++++++++++ packages/express/README.md | 66 +++++++++- packages/express/src/createServer.ts | 14 ++ packages/express/src/handlers/oauth.ts | 109 ++++++++++++++++ packages/express/tests/oauthRoutes.test.js | 86 +++++++++++++ 9 files changed, 637 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/handlers/oauthHandlers.ts create mode 100644 packages/core/tests/oauthHandlers.test.js create mode 100644 packages/express/src/handlers/oauth.ts create mode 100644 packages/express/tests/oauthRoutes.test.js diff --git a/README.md b/README.md index 510f13b..a77ad32 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Seamless Auth is an open source, passwordless authentication system designed to be embedded directly into applications. -It provides a small, explicit core and framework-specific adapters that make it easy to integrate secure, session-based authentication into APIs and web applications without redirects, third-party identity providers, or opaque middleware chains. +It provides a small, explicit core and framework-specific adapters that make it easy to integrate secure, session-based authentication into APIs and web applications without opaque middleware chains. Native passwordless flows stay embedded in your app, while optional OAuth routes let adopters add external identity providers when their product or enterprise customers need them. This repository contains the core building blocks and official server-side framework integrations. @@ -16,7 +16,7 @@ Seamless Auth is built around a few guiding principles: Authentication is based on possession and verification, not shared secrets. - **Embedded authentication** - Auth flows live inside your application. No redirects, callbacks, or hosted UI. + Native Seamless Auth flows live inside your application. OAuth redirects are available as an optional bridge to external identity providers. - **Server-side session validation** Sessions are managed using secure, HTTP-only cookies and validated by the API. @@ -42,6 +42,7 @@ Responsibilities include: - Verifying signed session cookies - Authenticated server-to-server requests - Resolving the current authenticated user +- Proxy-safe OAuth login helpers that never store provider access tokens - Shared types and helpers The core package does **not** depend on any specific web framework. @@ -82,6 +83,8 @@ It is also the natural initializer boundary for adopter-supplied auth messaging For WebAuthn PRF flows, the adapter proxies PRF registration query flags and assertion request bodies to the Seamless Auth API. PRF outputs remain browser-only and are never handled by the server adapter. +For OAuth flows, the adapter proxies provider discovery, OAuth start, and OAuth callback completion. The callback exchanges the provider authorization code at the private Seamless Auth API, then the adapter sets the normal signed access and refresh cookies for the application. + Location: ``` @@ -101,6 +104,61 @@ app.get( --- +## OAuth Login + +OAuth support is intentionally split across the same trust boundary as the rest of Seamless Auth: + +- the browser asks your app/API for enabled providers +- your server adapter proxies OAuth requests to the private Seamless Auth API +- Seamless Auth validates signed state, exchanges the provider code, links the provider identity, and issues a SeamlessAuth session +- the adapter stores only the resulting SeamlessAuth cookies + +Provider access tokens are not stored by the adapter, returned to the frontend, or placed in cookies. + +Mounted Express routes include: + +- `GET /auth/oauth/providers` +- `POST /auth/oauth/:providerId/start` +- `POST /auth/oauth/:providerId/callback` + +Example frontend sequence: + +```ts +const start = await fetch("/auth/oauth/google/start", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirectUri: `${window.location.origin}/oauth/callback`, + returnTo: `${window.location.origin}/dashboard`, + }), +}).then((response) => response.json()); + +window.location.assign(start.authorizationUrl); +``` + +After the provider redirects back: + +```ts +const params = new URLSearchParams(window.location.search); + +await fetch("/auth/oauth/google/callback", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: params.get("code"), + state: params.get("state"), + }), +}); +``` + +Configure providers on the Seamless Auth API using `oauth_providers` and `LOGIN_METHODS=...,oauth`. +Each provider references its client secret by environment variable name, for example +`clientSecretEnv: "GOOGLE_CLIENT_SECRET"`. + +--- + ## Extensibility Framework integrations are designed as thin adapters over the core package. diff --git a/packages/core/README.md b/packages/core/README.md index cd27ac6..937b992 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -23,6 +23,7 @@ If you are building a custom adapter (Express, Fastify, Next.js, Hono, etc.), th - Core authentication state machine - Cookie validation and refresh logic - Service-token–based API ↔ Auth Server communication +- Framework-agnostic OAuth provider discovery/start/callback helpers - Stateless, cryptographically verifiable flows This package **does not**: @@ -87,6 +88,9 @@ Key exports include: - `refreshAccessToken(...)` – rotates expired access sessions - `verifyCookieJwt(...)` – verifies signed cookie payloads - `createServiceToken(...)` – creates short-lived M2M assertions +- `listOAuthProvidersHandler(...)` – retrieves public OAuth provider metadata +- `startOAuthLoginHandler(...)` – starts an OAuth authorization-code login +- `finishOAuthLoginHandler(...)` – finishes OAuth login and returns cookie instructions These functions return **descriptive results**, not HTTP responses. @@ -121,6 +125,53 @@ if (result.type === "error") { --- +## OAuth Helper Flow + +The core OAuth helpers are designed for framework adapters. They do not redirect, set cookies, or +read secrets. They proxy to the Seamless Auth API and return plain result objects that your adapter +turns into HTTP responses. + +```ts +const providers = await listOAuthProvidersHandler({ + authServerUrl: "https://auth.example.com", +}); + +const started = await startOAuthLoginHandler( + { + providerId: "google", + body: { + redirectUri: "https://app.example.com/oauth/callback", + returnTo: "https://app.example.com/dashboard", + }, + }, + { authServerUrl: "https://auth.example.com" }, +); + +const finished = await finishOAuthLoginHandler( + { + providerId: "google", + body: { + code: "provider-code", + state: "signed-state-from-start", + }, + }, + { + authServerUrl: "https://auth.example.com", + accessCookieName: "seamless-access", + refreshCookieName: "seamless-refresh", + }, +); + +if (finished.setCookies) { + // adapter signs and applies access/refresh cookies +} +``` + +The Seamless Auth API handles state validation, provider token exchange, userinfo lookup, and +provider identity linking. Core and adapter code never store provider access tokens. + +--- + ## Testing All logic in this package is tested against compiled output (`dist/`), diff --git a/packages/core/src/handlers/oauthHandlers.ts b/packages/core/src/handlers/oauthHandlers.ts new file mode 100644 index 0000000..bac3164 --- /dev/null +++ b/packages/core/src/handlers/oauthHandlers.ts @@ -0,0 +1,134 @@ +import { authFetch } from "../authFetch.js"; +import type { CookiePayload } from "../ensureCookies.js"; +import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; + +export interface OAuthHandlerOptions { + authServerUrl: string; + cookieDomain?: string; + accessCookieName: string; + refreshCookieName: string; +} + +export interface OAuthRequestInput { + providerId?: string; + body?: unknown; + forwardedClientIp?: string; +} + +export interface OAuthHandlerResult { + status: number; + body?: unknown; + error?: unknown; + setCookies?: { + name: string; + value: CookiePayload; + ttl: number; + domain?: string; + }[]; +} + +export async function listOAuthProvidersHandler( + opts: Pick, +) { + const up = await authFetch(`${opts.authServerUrl}/oauth/providers`, { + method: "GET", + }); + + const data = await up.json(); + + return { + status: up.status, + ...(up.ok ? { body: data } : { error: data }), + }; +} + +export async function startOAuthLoginHandler( + input: OAuthRequestInput, + opts: Pick, +) { + const up = await authFetch( + `${opts.authServerUrl}/oauth/${encodeURIComponent(input.providerId!)}/start`, + { + method: "POST", + body: input.body, + forwardedClientIp: input.forwardedClientIp, + }, + ); + + const data = await up.json(); + + return { + status: up.status, + ...(up.ok ? { body: data } : { error: data }), + }; +} + +export async function finishOAuthLoginHandler( + input: OAuthRequestInput, + opts: OAuthHandlerOptions, +): Promise { + const up = await authFetch( + `${opts.authServerUrl}/oauth/${encodeURIComponent(input.providerId!)}/callback`, + { + method: "POST", + body: input.body, + forwardedClientIp: input.forwardedClientIp, + }, + ); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + const verifiedAccessToken = await verifySignedAuthResponse( + data.token, + opts.authServerUrl, + ); + + if (!verifiedAccessToken) { + throw new Error("Invalid signed response from Auth Server"); + } + + if (verifiedAccessToken.sub !== data.sub) { + throw new Error("Signature mismatch with data payload"); + } + + const sessionId = + typeof verifiedAccessToken.sid === "string" + ? verifiedAccessToken.sid + : undefined; + + return { + status: up.status, + body: data, + setCookies: [ + { + name: opts.accessCookieName, + value: { + sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), + roles: data.roles, + email: data.email, + phone: data.phone, + organizationId: data.organizationId ?? null, + }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + { + name: opts.refreshCookieName, + value: { + sub: data.sub, + refreshToken: data.refreshToken, + }, + ttl: data.refreshTtl, + domain: opts.cookieDomain, + }, + ], + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a064f67..339f1d6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,3 +18,4 @@ export * from "./handlers/verifyMagicLinkHandler.js"; export * from "./handlers/requestMagicLinkHandler.js"; export * from "./handlers/pollMagicLinkConfirmationHandler.js"; export * from "./handlers/switchOrganizationHandler.js"; +export * from "./handlers/oauthHandlers.js"; diff --git a/packages/core/tests/oauthHandlers.test.js b/packages/core/tests/oauthHandlers.test.js new file mode 100644 index 0000000..eba503f --- /dev/null +++ b/packages/core/tests/oauthHandlers.test.js @@ -0,0 +1,119 @@ +import { jest } from "@jest/globals"; + +const verifySignedAuthResponseMock = jest.fn(); + +jest.unstable_mockModule("../dist/verifySignedAuthResponse.js", () => ({ + verifySignedAuthResponse: verifySignedAuthResponseMock, +})); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe("oauthHandlers", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + verifySignedAuthResponseMock.mockReset(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("starts OAuth login through the auth server", async () => { + const { startOAuthLoginHandler } = await import( + "../dist/handlers/oauthHandlers.js" + ); + + global.fetch.mockResolvedValue( + createJsonResponse(200, { + authorizationUrl: "https://provider.example.com/auth", + state: "state", + }), + ); + + const result = await startOAuthLoginHandler( + { + providerId: "google", + body: { redirectUri: "https://app.example.com/oauth/callback" }, + }, + { authServerUrl: "https://auth.example.com" }, + ); + + expect(result).toEqual({ + status: 200, + body: { + authorizationUrl: "https://provider.example.com/auth", + state: "state", + }, + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/oauth/google/start", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("finishes OAuth login and returns access and refresh cookies", async () => { + const { finishOAuthLoginHandler } = await import( + "../dist/handlers/oauthHandlers.js" + ); + + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Success", + token: "access-token", + refreshToken: "refresh-token", + sub: "user-123", + roles: ["user"], + email: "person@example.com", + phone: "oauth:google:provider-user", + organizationId: "org-123", + ttl: 900, + refreshTtl: 3600, + }), + ); + verifySignedAuthResponseMock.mockResolvedValue({ + sub: "user-123", + sid: "session-123", + }); + + const result = await finishOAuthLoginHandler( + { + providerId: "google", + body: { code: "code", state: "state" }, + }, + { + authServerUrl: "https://auth.example.com", + accessCookieName: "access", + refreshCookieName: "refresh", + }, + ); + + expect(result.status).toBe(200); + expect(result.setCookies).toEqual([ + expect.objectContaining({ + name: "access", + value: expect.objectContaining({ + sub: "user-123", + sessionId: "session-123", + organizationId: "org-123", + }), + }), + expect.objectContaining({ + name: "refresh", + value: { + sub: "user-123", + refreshToken: "refresh-token", + }, + }), + ]); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index c8fb9ad..2c390db 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -14,6 +14,7 @@ This package: - Manages signed, HttpOnly session cookies - Enforces authentication and authorization in your API - Handles all API ↔ Auth Server communication via short-lived service tokens +- Proxies optional OAuth login flows and converts successful callbacks into app cookies - Establishes the initializer surface for adopter-supplied auth messaging > **npm:** https://www.npmjs.com/package/@seamless-auth/express @@ -133,8 +134,10 @@ Mounts an Express router that exposes the full Seamless Auth flow under `/auth`. Routes include: -- `/auth/login/start` -- `/auth/login/finish` +- `/auth/login` +- `/auth/oauth/providers` +- `/auth/oauth/:providerId/start` +- `/auth/oauth/:providerId/callback` - `/auth/webauthn/*` - `/auth/step-up/*` - `/auth/registration/*` @@ -173,6 +176,60 @@ This currently applies to: --- +### OAuth Login Routes + +When OAuth is enabled in the Seamless Auth API system config, the Express adapter exposes the +provider flow under your mounted `/auth` path: + +- `GET /auth/oauth/providers` returns public provider metadata such as `id`, `name`, and scopes. +- `POST /auth/oauth/:providerId/start` returns a signed `state` and provider `authorizationUrl`. +- `POST /auth/oauth/:providerId/callback` accepts the provider `code` and `state`, then sets the + same signed access/refresh cookies as passkey, OTP, or magic-link login. + +Example OAuth start from a browser: + +```ts +const result = await fetch("/auth/oauth/google/start", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirectUri: `${window.location.origin}/oauth/callback`, + returnTo: `${window.location.origin}/dashboard`, + }), +}).then((response) => response.json()); + +window.location.assign(result.authorizationUrl); +``` + +Example callback page: + +```ts +const params = new URLSearchParams(window.location.search); + +const response = await fetch("/auth/oauth/google/callback", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: params.get("code"), + state: params.get("state"), + }), +}); + +if (response.ok) { + window.location.assign("/dashboard"); +} +``` + +Provider configuration belongs on the Seamless Auth API, not in the Express adapter. Configure +`LOGIN_METHODS` to include `oauth`, add `oauth_providers`, and store provider client secrets in +server environment variables referenced by `clientSecretEnv`. + +Provider access tokens are never stored in adapter cookies or returned to the frontend. + +--- + ### `requireAuth(options?)` Express middleware that verifies a signed access cookie and attaches the decoded user payload to `req.user`. @@ -224,7 +281,7 @@ Returned shape (example): ## End-to-End Flow -1. **Frontend** → `/auth/login/start` +1. **Frontend** → `/auth/login` API proxies request and sets a short-lived _pre-auth_ cookie. 2. **Frontend** → `/auth/webauthn/finish` @@ -233,6 +290,9 @@ Returned shape (example): 3. **API routes** → `/api/*` `requireAuth()` verifies the cookie and attaches `req.user`. +For OAuth, the initial identifier step is replaced by `/auth/oauth/:providerId/start`; the callback +completion route sets the same authenticated cookies used by the rest of the adapter. + --- ## Local Development diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 3c8fd59..9b4a61a 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -15,6 +15,11 @@ import { me } from "./handlers/me"; import { logout } from "./handlers/logout"; import { pollMagicLinkConfirmation } from "./handlers/pollMagicLinkConfirmation"; import { requestMagicLink } from "./handlers/requestMagicLink"; +import { + finishOAuthLogin, + listOAuthProviders, + startOAuthLogin, +} from "./handlers/oauth"; import * as admin from "./handlers/admin"; import { authFetch, AuthFetchOptions } from "@seamless-auth/core"; import { buildServiceAuthorization } from "./internal/buildAuthorization"; @@ -299,6 +304,15 @@ export function createSeamlessAuthServer( ); r.post("/login", (req, res) => login(req, res, resolvedOpts)); + r.get("/oauth/providers", (req, res) => + listOAuthProviders(req, res, resolvedOpts), + ); + r.post("/oauth/:providerId/start", (req, res) => + startOAuthLogin(req, res, resolvedOpts), + ); + r.post("/oauth/:providerId/callback", (req, res) => + finishOAuthLogin(req, res, resolvedOpts), + ); r.post("/registration/register", (req, res) => register(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/oauth.ts b/packages/express/src/handlers/oauth.ts new file mode 100644 index 0000000..a23a7ae --- /dev/null +++ b/packages/express/src/handlers/oauth.ts @@ -0,0 +1,109 @@ +import { Request, Response } from "express"; +import { + finishOAuthLoginHandler, + listOAuthProvidersHandler, + startOAuthLoginHandler, +} from "@seamless-auth/core/handlers/oauthHandlers"; +import { SeamlessAuthServerOptions } from "../createServer"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { setSessionCookie } from "../internal/cookie"; + +function cookieSigner(opts: SeamlessAuthServerOptions) { + if (!opts.cookieSecret) { + throw new Error("Missing COOKIE_SIGNING_KEY"); + } + + return { + secret: opts.cookieSecret, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "none" + : ("lax" as "none" | "lax"), + }; +} + +function routeParam(req: Request, name: string): string { + const value = req.params[name]; + return Array.isArray(value) ? value[0] : value; +} + +export async function listOAuthProviders( + _req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const result = await listOAuthProvidersHandler({ + authServerUrl: opts.authServerUrl, + }); + + if ("error" in result) { + return res.status(result.status).json(result.error); + } + + return res.status(result.status).json(result.body); +} + +export async function startOAuthLogin( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const result = await startOAuthLoginHandler( + { + providerId: routeParam(req, "providerId"), + body: req.body, + forwardedClientIp: buildForwardedClientIp(req), + }, + { + authServerUrl: opts.authServerUrl, + }, + ); + + if ("error" in result) { + return res.status(result.status).json(result.error); + } + + return res.status(result.status).json(result.body); +} + +export async function finishOAuthLogin( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const result = await finishOAuthLoginHandler( + { + providerId: routeParam(req, "providerId"), + body: req.body, + forwardedClientIp: buildForwardedClientIp(req), + }, + { + authServerUrl: opts.authServerUrl, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName!, + refreshCookieName: opts.refreshCookieName!, + }, + ); + + if (result.setCookies) { + for (const c of result.setCookies) { + setSessionCookie( + res, + { + name: c.name, + payload: c.value, + domain: c.domain, + ttlSeconds: c.ttl, + }, + cookieSigner(opts), + ); + } + } + + if (result.error) { + return res.status(result.status).json(result.error); + } + + return res.status(result.status).json(result.body); +} diff --git a/packages/express/tests/oauthRoutes.test.js b/packages/express/tests/oauthRoutes.test.js new file mode 100644 index 0000000..3e24705 --- /dev/null +++ b/packages/express/tests/oauthRoutes.test.js @@ -0,0 +1,86 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("OAuth routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proxies provider listing", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + providers: [{ id: "google", name: "Google", scopes: ["openid"] }], + }), + ); + + const res = await request(createApp()).get("/auth/oauth/providers"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + providers: [{ id: "google", name: "Google", scopes: ["openid"] }], + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/oauth/providers", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("proxies OAuth start requests", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + authorizationUrl: "https://provider.example.com/auth", + state: "state", + }), + ); + + const body = { redirectUri: "https://app.example.com/oauth/callback" }; + + const res = await request(createApp()) + .post("/auth/oauth/google/start") + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/oauth/google/start", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); +});