From 9c56cf5dd652c5616dbceec86da6b9caeb8efd8e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 17:48:34 -0400 Subject: [PATCH 1/2] feat: oauth addtion --- README.md | 80 ++++- src/config/systemConfig.defaults.ts | 1 + src/config/systemConfig.envMap.ts | 1 + src/controllers/oauth.ts | 154 +++++++++ .../20260519100000-add-oauth-support.cjs | 77 +++++ src/models/oauthIdentities.ts | 96 ++++++ src/models/users.ts | 9 + src/routes/oauth.routes.ts | 56 ++++ src/schemas/authEvent.types.ts | 3 + src/schemas/oauth.requests.ts | 21 ++ src/schemas/systemConfig.schema.ts | 27 +- src/services/loginPolicyService.ts | 14 +- src/services/oauthService.ts | 312 ++++++++++++++++++ src/utils/parseEnvConfigs.ts | 3 + tests/factories/systemConfigFactory.ts | 1 + tests/integration/oauth/oauth.spec.ts | 149 +++++++++ tests/setup/mocks.ts | 10 + tests/unit/models/models.spec.ts | 1 + tests/unit/services/oauthService.spec.ts | 155 +++++++++ 19 files changed, 1165 insertions(+), 5 deletions(-) create mode 100644 src/controllers/oauth.ts create mode 100644 src/migrations/20260519100000-add-oauth-support.cjs create mode 100644 src/models/oauthIdentities.ts create mode 100644 src/routes/oauth.routes.ts create mode 100644 src/schemas/oauth.requests.ts create mode 100644 src/services/oauthService.ts create mode 100644 tests/integration/oauth/oauth.spec.ts create mode 100644 tests/unit/services/oauthService.spec.ts diff --git a/README.md b/README.md index d2ed431..03d6019 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository intentionally focuses on **authentication only**. ### What this repository includes - Passwordless authentication flows (e.g. passkeys, OTP where configured) +- Optional OAuth login through configured external identity providers - Secure session and token handling - User registration and authentication APIs - WebAuthn / Passkeys support @@ -105,13 +106,88 @@ contract guidance, and Seamless Secrets consumption rules. ### Login Method Policy Administrators can control which methods may continue after `/login` creates a pre-authenticated -session. Configure `LOGIN_METHODS` with any of `passkey`, `magic_link`, `email_otp`, or -`phone_otp`. The default is `passkey,magic_link`. +session. Configure `LOGIN_METHODS` with any of `passkey`, `magic_link`, `email_otp`, `phone_otp`, +or `oauth`. The default is `passkey,magic_link`. Set `PASSKEY_LOGIN_FALLBACK_ENABLED=false` when passkey-capable sessions should continue with passkeys only. When fallback is enabled, `/login` returns `loginMethods` so clients can show only the allowed continuations for that user and device. +### OAuth Login + +OAuth support lets adopters offer login with external providers such as Google, GitHub, Facebook, +or any compatible provider that supports an authorization-code exchange and a userinfo endpoint. +Seamless Auth still issues the final SeamlessAuth session. Provider access tokens are used only +during the callback to fetch the profile; they are not logged, stored, returned to clients, or +included in API responses. + +Enable OAuth by adding `oauth` to `LOGIN_METHODS` and configuring `oauth_providers` in +`system_config` or the `OAUTH_PROVIDERS` environment variable. `OAUTH_PROVIDERS` is JSON. Secrets +are referenced by environment variable name through `clientSecretEnv`; do not put client secrets in +system config. + +```json +[ + { + "id": "google", + "name": "Google", + "enabled": true, + "clientId": "google-oauth-client-id", + "clientSecretEnv": "GOOGLE_CLIENT_SECRET", + "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenUrl": "https://oauth2.googleapis.com/token", + "userInfoUrl": "https://openidconnect.googleapis.com/v1/userinfo", + "scopes": ["openid", "email", "profile"], + "redirectUri": "https://app.example.com/oauth/callback", + "subjectJsonPath": "sub", + "emailJsonPath": "email", + "nameJsonPath": "name", + "allowSignup": true + } +] +``` + +The browser/client flow is: + +1. `GET /oauth/providers` returns enabled public provider metadata. +2. `POST /oauth/:providerId/start` returns a signed `state` and provider `authorizationUrl`. +3. The browser redirects to `authorizationUrl`. +4. The provider redirects back to your `redirectUri` with `code` and `state`. +5. The client posts `{ code, state }` to `POST /oauth/:providerId/callback`. +6. Seamless Auth validates state, exchanges the code, fetches userinfo, links or creates the local + user, and issues the normal SeamlessAuth access/refresh session. + +Example direct API start request: + +```bash +curl -X POST http://localhost:5312/oauth/google/start \ + -H 'Content-Type: application/json' \ + -d '{ + "redirectUri": "http://localhost:5173/oauth/callback", + "returnTo": "http://localhost:5173/dashboard" + }' +``` + +Example callback request after the provider redirects back: + +```bash +curl -X POST http://localhost:5312/oauth/google/callback \ + -H 'Content-Type: application/json' \ + -d '{ + "code": "provider-authorization-code", + "state": "signed-state-from-start" + }' +``` + +Security notes: + +- OAuth `state` is signed and expires after a short window. +- `redirectUri` and `returnTo` must match configured `origins`. +- Provider access tokens are never persisted. +- OAuth identities are stored as provider id + provider subject in `oauth_identities`. +- Existing users are linked by verified email; new users are created only when `allowSignup` is + enabled for that provider. + ### Install & run ``` diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index 7dbdcf8..b481415 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -8,5 +8,6 @@ import type { SystemConfig } from '../schemas/systemConfig.schema.js'; export const SYSTEM_CONFIG_DEFAULTS: Partial = { login_methods: ['passkey', 'magic_link'], + oauth_providers: [], passkey_login_fallback_enabled: true, }; diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index fcd4f4e..0a39b4c 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -8,6 +8,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { default_roles: 'DEFAULT_ROLES', available_roles: 'AVAILABLE_ROLES', login_methods: 'LOGIN_METHODS', + oauth_providers: 'OAUTH_PROVIDERS', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', access_token_ttl: 'ACCESS_TOKEN_TTL', refresh_token_ttl: 'REFRESH_TOKEN_TTL', diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts new file mode 100644 index 0000000..5e0b1e0 --- /dev/null +++ b/src/controllers/oauth.ts @@ -0,0 +1,154 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { + buildOAuthAuthorizationUrl, + createOAuthState, + exchangeOAuthCode, + fetchOAuthProfile, + getEnabledOAuthProviders, + getOAuthProvider, + resolveOAuthRedirectUri, + resolveOAuthUser, + serializeOAuthProvider, + verifyOAuthState, +} from '../services/oauthService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; + +const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; + +function allowedReturnTo(value: string | undefined, origins: string[]) { + if (!value) return undefined; + return origins.some((origin) => value.startsWith(origin)) ? value : undefined; +} + +export async function listOAuthProviders(_req: Request, res: Response) { + const providers = await getEnabledOAuthProviders(); + + return res.json({ + providers: providers.map(serializeOAuthProvider), + }); +} + +export async function startOAuthLogin(req: Request, res: Response) { + const { providerId } = req.params; + const provider = await getOAuthProvider(providerId); + + if (!provider) { + return res.status(404).json({ error: 'OAuth provider not found' }); + } + + try { + const config = await getSystemConfig(); + const redirectUri = await resolveOAuthRedirectUri(provider, req.body.redirectUri); + const returnTo = allowedReturnTo(req.body.returnTo, config.origins); + const state = createOAuthState({ + providerId: provider.id, + redirectUri, + ...(returnTo ? { returnTo } : {}), + }); + + await AuthEventService.log({ + type: 'oauth_login_started', + req, + metadata: { providerId: provider.id }, + }); + + return res.json({ + provider: serializeOAuthProvider(provider), + state, + authorizationUrl: buildOAuthAuthorizationUrl({ + provider, + redirectUri, + state, + }), + }); + } catch (error) { + await AuthEventService.log({ + type: 'oauth_login_failed', + req, + metadata: { providerId: provider.id, reason: 'start_failed' }, + }); + + return res.status(400).json({ + error: error instanceof Error ? error.message : 'OAuth start failed', + }); + } +} + +export async function finishOAuthLogin(req: Request, res: Response) { + const { providerId } = req.params; + const { code, state } = req.body; + const provider = await getOAuthProvider(providerId); + + if (!provider) { + return res.status(404).json({ error: 'OAuth provider not found' }); + } + + const statePayload = verifyOAuthState(state, provider.id); + + if (!statePayload) { + await AuthEventService.log({ + type: 'oauth_login_failed', + req, + metadata: { providerId: provider.id, reason: 'invalid_state' }, + }); + return res.status(400).json({ error: 'Invalid OAuth state' }); + } + + try { + const accessToken = await exchangeOAuthCode({ + provider, + code, + redirectUri: statePayload.redirectUri, + }); + const profile = await fetchOAuthProfile(provider, accessToken); + const user = await resolveOAuthUser(provider, profile); + + if (!user) { + await AuthEventService.log({ + type: 'oauth_login_failed', + req, + metadata: { providerId: provider.id, reason: 'signup_disabled' }, + }); + return res.status(403).json({ error: 'OAuth signup is disabled' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'oauth_login_success', + req, + metadata: { providerId: provider.id }, + }); + + return issueSessionAndRespond({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, + clearExistingCookies: true, + }); + } catch (error) { + await AuthEventService.log({ + type: 'oauth_login_failed', + req, + metadata: { providerId: provider.id, reason: 'callback_failed' }, + }); + + return res.status(400).json({ + error: error instanceof Error ? error.message : 'OAuth login failed', + }); + } +} diff --git a/src/migrations/20260519100000-add-oauth-support.cjs b/src/migrations/20260519100000-add-oauth-support.cjs new file mode 100644 index 0000000..a96e9ad --- /dev/null +++ b/src/migrations/20260519100000-add-oauth-support.cjs @@ -0,0 +1,77 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('oauth_identities', { + id: { + type: Sequelize.UUID, + primaryKey: true, + allowNull: false, + defaultValue: Sequelize.literal('gen_random_uuid()'), + }, + user_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + provider_id: { + type: Sequelize.STRING, + allowNull: false, + }, + provider_subject: { + type: Sequelize.STRING, + allowNull: false, + }, + email: { + type: Sequelize.STRING, + allowNull: false, + }, + profile: { + type: Sequelize.JSONB, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + await queryInterface.addIndex('oauth_identities', ['provider_id', 'provider_subject'], { + unique: true, + name: 'idx_oauth_identities_provider_subject_unique', + }); + await queryInterface.addIndex('oauth_identities', ['user_id'], { + name: 'idx_oauth_identities_user_id', + }); + + await queryInterface.sequelize.query(` + INSERT INTO system_config (key, value, "updatedBy", "createdAt", "updatedAt") + VALUES ('oauth_providers', '[]'::jsonb, NULL, NOW(), NOW()) + ON CONFLICT (key) DO NOTHING; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DELETE FROM system_config + WHERE key = 'oauth_providers'; + `); + await queryInterface.dropTable('oauth_identities'); + }, +}; diff --git a/src/models/oauthIdentities.ts b/src/models/oauthIdentities.ts new file mode 100644 index 0000000..436537c --- /dev/null +++ b/src/models/oauthIdentities.ts @@ -0,0 +1,96 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { DataTypes, Model, Sequelize } from 'sequelize'; + +import type { User } from './users.js'; + +export interface OAuthIdentityAttributes { + id?: string; + userId: string; + providerId: string; + providerSubject: string; + email: string; + profile?: Record | null; + createdAt?: Date; + updatedAt?: Date; +} + +export class OAuthIdentity + extends Model + implements OAuthIdentityAttributes +{ + declare id: string; + declare userId: string; + declare providerId: string; + declare providerSubject: string; + declare email: string; + declare profile: Record | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + declare readonly user?: User; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static associate(models: any) { + OAuthIdentity.belongsTo(models.User, { + foreignKey: 'userId', + onDelete: 'CASCADE', + as: 'user', + }); + } +} + +const initializeOAuthIdentityModel = (sequelize: Sequelize) => { + OAuthIdentity.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + providerId: { + type: DataTypes.STRING, + allowNull: false, + }, + providerSubject: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + }, + profile: { + type: DataTypes.JSONB, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'OAuthIdentity', + tableName: 'oauth_identities', + underscored: true, + indexes: [ + { + unique: true, + fields: ['provider_id', 'provider_subject'], + }, + { + fields: ['user_id'], + }, + ], + }, + ); + + return OAuthIdentity; +}; + +export default initializeOAuthIdentityModel; diff --git a/src/models/users.ts b/src/models/users.ts index 800338a..191f4a6 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -7,6 +7,7 @@ import { Association, DataTypes, Model, Sequelize } from 'sequelize'; import type { Credential } from './credentials.js'; +import type { OAuthIdentity } from './oauthIdentities.js'; import type { OrganizationMembership } from './organizationMemberships.js'; import type { TotpCredential } from './totpCredentials.js'; @@ -29,6 +30,7 @@ export interface UserAttributes { createdAt?: Date; updatedAt?: Date; credentials?: Credential[]; + oauthIdentities?: OAuthIdentity[]; organizationMemberships?: OrganizationMembership[]; totpCredentials?: TotpCredential[]; } @@ -52,11 +54,13 @@ export class User extends Model implements UserAttributes { declare readonly createdAt: Date; declare readonly updatedAt: Date; declare readonly credentials?: Credential[]; + declare readonly oauthIdentities?: OAuthIdentity[]; declare readonly organizationMemberships?: OrganizationMembership[]; declare readonly totpCredentials?: TotpCredential[]; public static associations: { credentials: Association; + oauthIdentities: Association; organizationMemberships: Association; totpCredentials: Association; }; @@ -68,6 +72,11 @@ export class User extends Model implements UserAttributes { onDelete: 'CASCADE', as: 'credentials', }); + User.hasMany(models.OAuthIdentity, { + foreignKey: 'userId', + onDelete: 'CASCADE', + as: 'oauthIdentities', + }); User.hasMany(models.TotpCredential, { foreignKey: 'userId', onDelete: 'CASCADE', diff --git a/src/routes/oauth.routes.ts b/src/routes/oauth.routes.ts new file mode 100644 index 0000000..51d92f9 --- /dev/null +++ b/src/routes/oauth.routes.ts @@ -0,0 +1,56 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + finishOAuthLogin, + listOAuthProviders, + startOAuthLogin, +} from '../controllers/oauth.js'; +import { createRouter } from '../lib/createRouter.js'; +import { + FinishOAuthLoginRequestSchema, + OAuthProviderParamSchema, + StartOAuthLoginRequestSchema, +} from '../schemas/oauth.requests.js'; + +const oauthRouter = createRouter('/oauth'); + +oauthRouter.get( + '/providers', + { + summary: 'List enabled OAuth providers', + tags: ['OAuth'], + }, + listOAuthProviders, +); + +oauthRouter.post( + '/:providerId/start', + { + summary: 'Start OAuth login', + tags: ['OAuth'], + schemas: { + params: OAuthProviderParamSchema, + body: StartOAuthLoginRequestSchema, + }, + }, + startOAuthLogin, +); + +oauthRouter.post( + '/:providerId/callback', + { + summary: 'Finish OAuth login', + tags: ['OAuth'], + schemas: { + params: OAuthProviderParamSchema, + body: FinishOAuthLoginRequestSchema, + }, + }, + finishOAuthLogin, +); + +export default oauthRouter.router; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index d646f7f..247baef 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -37,6 +37,9 @@ export const AUTH_EVENT_TYPES = [ 'mfa_otp_success', 'mfa_otp_suspicious', 'notification_sent', + 'oauth_login_failed', + 'oauth_login_started', + 'oauth_login_success', 'otp_failed', 'otp_success', 'otp_suspicious', diff --git a/src/schemas/oauth.requests.ts b/src/schemas/oauth.requests.ts new file mode 100644 index 0000000..bab1679 --- /dev/null +++ b/src/schemas/oauth.requests.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +export const OAuthProviderParamSchema = z.object({ + providerId: z.string().regex(/^[a-z0-9-]{2,40}$/), +}); + +export const StartOAuthLoginRequestSchema = z.object({ + redirectUri: z.url().optional(), + returnTo: z.url().optional(), +}); + +export const FinishOAuthLoginRequestSchema = z.object({ + code: z.string().trim().min(1), + state: z.string().trim().min(1), +}); diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index 2cd45a8..e8c175e 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -6,7 +6,30 @@ import { z } from 'zod'; -export const LoginMethodSchema = z.enum(['passkey', 'magic_link', 'email_otp', 'phone_otp']); +export const LoginMethodSchema = z.enum([ + 'passkey', + 'magic_link', + 'email_otp', + 'phone_otp', + 'oauth', +]); + +export const OAuthProviderConfigSchema = z.object({ + id: z.string().regex(/^[a-z0-9-]{2,40}$/), + name: z.string().trim().min(1).max(80), + enabled: z.boolean().default(true), + clientId: z.string().trim().min(1), + clientSecretEnv: z.string().trim().min(1), + authorizationUrl: z.url(), + tokenUrl: z.url(), + userInfoUrl: z.url(), + scopes: z.array(z.string().trim().min(1)).default([]), + redirectUri: z.url().optional(), + subjectJsonPath: z.string().trim().min(1).default('sub'), + emailJsonPath: z.string().trim().min(1).default('email'), + nameJsonPath: z.string().trim().min(1).optional(), + allowSignup: z.boolean().default(true), +}); export const SystemConfigSchema = z.object({ app_name: z.string().min(3), @@ -14,6 +37,7 @@ export const SystemConfigSchema = z.object({ available_roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), login_methods: z.array(LoginMethodSchema).min(1), passkey_login_fallback_enabled: z.boolean(), + oauth_providers: z.array(OAuthProviderConfigSchema), access_token_ttl: z.string().regex(/^\d+[smhd]$/), refresh_token_ttl: z.string().regex(/^\d+[smhd]$/), @@ -26,3 +50,4 @@ export const SystemConfigSchema = z.object({ }); export type SystemConfig = z.infer; +export type OAuthProviderConfig = z.infer; diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts index 1577850..1054408 100644 --- a/src/services/loginPolicyService.ts +++ b/src/services/loginPolicyService.ts @@ -8,7 +8,7 @@ import { getSystemConfig } from '../config/getSystemConfig.js'; import { SYSTEM_CONFIG_DEFAULTS } from '../config/systemConfig.defaults.js'; import { LoginMethodSchema } from '../schemas/systemConfig.schema.js'; -export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp'; +export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth'; export interface LoginPolicy { loginMethods: LoginMethod[]; @@ -20,7 +20,13 @@ type LoginMethodUser = { phone?: string | null; }; -const LOGIN_METHOD_ORDER: LoginMethod[] = ['passkey', 'magic_link', 'email_otp', 'phone_otp']; +const LOGIN_METHOD_ORDER: LoginMethod[] = [ + 'passkey', + 'magic_link', + 'email_otp', + 'phone_otp', + 'oauth', +]; function hasValue(value: string | null | undefined) { return typeof value === 'string' && value.trim().length > 0; @@ -92,6 +98,10 @@ export function resolveAvailableLoginMethods({ return hasValue(user.email); } + if (method === 'oauth') { + return false; + } + return hasValue(user.phone); }); } diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts new file mode 100644 index 0000000..2307c26 --- /dev/null +++ b/src/services/oauthService.ts @@ -0,0 +1,312 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { OAuthIdentity } from '../models/oauthIdentities.js'; +import { User } from '../models/users.js'; +import type { OAuthProviderConfig } from '../schemas/systemConfig.schema.js'; + +const STATE_TTL_MS = 10 * 60 * 1000; + +export type PublicOAuthProvider = { + id: string; + name: string; + scopes: string[]; +}; + +export type OAuthStatePayload = { + providerId: string; + redirectUri: string; + returnTo?: string; + nonce: string; + createdAt: number; +}; + +export type OAuthProfile = { + subject: string; + email: string; + name?: string; + raw: Record; +}; + +function stateSecret() { + const explicit = process.env.OAUTH_STATE_SECRET?.trim(); + if (explicit) return explicit; + + const serviceSecret = process.env.API_SERVICE_TOKEN?.trim(); + if (serviceSecret) return serviceSecret; + + if (process.env.NODE_ENV !== 'production') { + return `dev-oauth-state:${process.env.APP_ID ?? 'local'}`; + } + + throw new Error('OAUTH_STATE_SECRET or API_SERVICE_TOKEN is required in production.'); +} + +function base64UrlEncode(value: string) { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function base64UrlDecode(value: string) { + return Buffer.from(value, 'base64url').toString('utf8'); +} + +function signPayload(payload: string) { + return createHmac('sha256', stateSecret()).update(payload).digest('base64url'); +} + +function safeEqual(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + + if (left.length !== right.length) return false; + + return timingSafeEqual(left, right); +} + +function getJsonPathValue(input: Record, path?: string) { + if (!path) return undefined; + + return path.split('.').reduce((current, segment) => { + if (!current || typeof current !== 'object') return undefined; + return (current as Record)[segment]; + }, input); +} + +function normalizeEmail(value: unknown) { + return typeof value === 'string' && value.includes('@') ? value.toLowerCase() : null; +} + +function allowedRedirect(value: string, origins: string[]) { + return origins.some((origin) => value.startsWith(origin)); +} + +export async function getEnabledOAuthProviders() { + const config = await getSystemConfig(); + + if (!config.login_methods.includes('oauth')) { + return []; + } + + return config.oauth_providers.filter((provider) => provider.enabled); +} + +export function serializeOAuthProvider(provider: OAuthProviderConfig): PublicOAuthProvider { + return { + id: provider.id, + name: provider.name, + scopes: provider.scopes ?? [], + }; +} + +export async function getOAuthProvider(providerId: string) { + const providers = await getEnabledOAuthProviders(); + return providers.find((provider) => provider.id === providerId) ?? null; +} + +export async function resolveOAuthRedirectUri( + provider: OAuthProviderConfig, + requestedRedirectUri?: string, +) { + const config = await getSystemConfig(); + + if (requestedRedirectUri) { + if (!allowedRedirect(requestedRedirectUri, config.origins)) { + throw new Error('OAuth redirect URI is not allowed'); + } + + return requestedRedirectUri; + } + + if (provider.redirectUri) { + return provider.redirectUri; + } + + return `${config.origins[0].replace(/\/$/, '')}/oauth/callback`; +} + +export function createOAuthState(payload: Omit) { + const statePayload: OAuthStatePayload = { + ...payload, + nonce: randomBytes(16).toString('base64url'), + createdAt: Date.now(), + }; + const encodedPayload = base64UrlEncode(JSON.stringify(statePayload)); + const signature = signPayload(encodedPayload); + + return `${encodedPayload}.${signature}`; +} + +export function verifyOAuthState(state: string, providerId: string): OAuthStatePayload | null { + const [encodedPayload, signature] = state.split('.'); + + if (!encodedPayload || !signature) return null; + if (!safeEqual(signPayload(encodedPayload), signature)) return null; + + const payload = JSON.parse(base64UrlDecode(encodedPayload)) as OAuthStatePayload; + + if (payload.providerId !== providerId) return null; + if (Date.now() - payload.createdAt > STATE_TTL_MS) return null; + + return payload; +} + +export function buildOAuthAuthorizationUrl({ + provider, + redirectUri, + state, +}: { + provider: OAuthProviderConfig; + redirectUri: string; + state: string; +}) { + const url = new URL(provider.authorizationUrl); + + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', provider.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + + if (provider.scopes.length) { + url.searchParams.set('scope', provider.scopes.join(' ')); + } + + return url.toString(); +} + +export async function exchangeOAuthCode({ + provider, + code, + redirectUri, +}: { + provider: OAuthProviderConfig; + code: string; + redirectUri: string; +}) { + const clientSecret = process.env[provider.clientSecretEnv]; + + if (!clientSecret) { + throw new Error(`OAuth client secret env "${provider.clientSecretEnv}" is not configured`); + } + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: provider.clientId, + client_secret: clientSecret, + }); + + const response = await globalThis.fetch(provider.tokenUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + if (!response.ok) { + throw new Error(`OAuth token exchange failed with status ${response.status}`); + } + + const tokenResponse = (await response.json()) as Record; + const accessToken = tokenResponse.access_token; + + if (typeof accessToken !== 'string' || !accessToken) { + throw new Error('OAuth token response did not include an access token'); + } + + return accessToken; +} + +export async function fetchOAuthProfile(provider: OAuthProviderConfig, accessToken: string) { + const response = await globalThis.fetch(provider.userInfoUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`OAuth profile fetch failed with status ${response.status}`); + } + + const raw = (await response.json()) as Record; + const subject = getJsonPathValue(raw, provider.subjectJsonPath); + const email = normalizeEmail(getJsonPathValue(raw, provider.emailJsonPath)); + const name = getJsonPathValue(raw, provider.nameJsonPath); + + if (typeof subject !== 'string' && typeof subject !== 'number') { + throw new Error('OAuth profile did not include a provider subject'); + } + + if (!email) { + throw new Error('OAuth profile did not include an email address'); + } + + return { + subject: String(subject), + email, + ...(typeof name === 'string' ? { name } : {}), + raw, + } satisfies OAuthProfile; +} + +export async function resolveOAuthUser(provider: OAuthProviderConfig, profile: OAuthProfile) { + const existingIdentity = await OAuthIdentity.findOne({ + where: { + providerId: provider.id, + providerSubject: profile.subject, + }, + }); + + if (existingIdentity) { + const user = await User.findByPk(existingIdentity.userId); + if (user) return user; + } + + let user = await User.findOne({ where: { email: profile.email } }); + + if (!user) { + if (!provider.allowSignup) { + return null; + } + + const config = await getSystemConfig(); + + user = await User.create({ + email: profile.email, + phone: `oauth:${provider.id}:${profile.subject}`.slice(0, 255), + roles: config.default_roles ?? [], + verified: true, + emailVerified: true, + phoneVerified: false, + }); + } + + await OAuthIdentity.findOrCreate({ + where: { + providerId: provider.id, + providerSubject: profile.subject, + }, + defaults: { + userId: user.id, + providerId: provider.id, + providerSubject: profile.subject, + email: profile.email, + profile: { + email: profile.email, + name: profile.name ?? null, + }, + }, + }); + + return user; +} diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 64a1354..67f8573 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -17,6 +17,9 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA .map((v) => v.trim()) .filter(Boolean); + case 'oauth_providers': + return JSON.parse(raw); + case 'rate_limit': case 'delay_after': return Number(raw); diff --git a/tests/factories/systemConfigFactory.ts b/tests/factories/systemConfigFactory.ts index 185a824..9a98ba1 100644 --- a/tests/factories/systemConfigFactory.ts +++ b/tests/factories/systemConfigFactory.ts @@ -4,6 +4,7 @@ export function buildSystemConfig(overrides: any = {}) { default_roles: ['user'], available_roles: ['user', 'admin'], login_methods: ['passkey', 'magic_link'], + oauth_providers: [], passkey_login_fallback_enabled: true, access_token_ttl: '15m', refresh_token_ttl: '7d', diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts new file mode 100644 index 0000000..4ffd75f --- /dev/null +++ b/tests/integration/oauth/oauth.spec.ts @@ -0,0 +1,149 @@ +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Application } from 'express'; + +import { createApp } from '../../../src/app.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { + createRefreshTokenLookup, + generateRefreshToken, + hashRefreshToken, + signAccessToken, +} from '../../../src/lib/token.js'; +import { OAuthIdentity } from '../../../src/models/oauthIdentities.js'; +import { Session } from '../../../src/models/sessions.js'; +import { User } from '../../../src/models/users.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; +import { buildUser } from '../../factories/userFactory.js'; + +let app: Application; + +const provider = { + id: 'google', + name: 'Google', + enabled: true, + clientId: 'client-id', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + scopes: ['openid', 'email'], + redirectUri: 'http://localhost:5174/oauth/callback', + subjectJsonPath: 'sub', + emailJsonPath: 'email', + nameJsonPath: 'name', + allowSignup: true, +}; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('AUTH_MODE', 'server'); + vi.stubEnv('GOOGLE_CLIENT_SECRET', 'secret'); + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'oauth'], + oauth_providers: [provider], + }), + ); +}); + +describe('OAuth routes', () => { + it('lists enabled OAuth providers without secrets', async () => { + const res = await request(app).get('/oauth/providers'); + + expect(res.status).toBe(200); + expect(res.body.providers).toEqual([ + { + id: 'google', + name: 'Google', + scopes: ['openid', 'email'], + }, + ]); + expect(JSON.stringify(res.body)).not.toContain('GOOGLE_CLIENT_SECRET'); + }); + + it('starts OAuth login with a signed state and authorization URL', async () => { + const res = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174/oauth/callback', + returnTo: 'http://localhost:5174/dashboard', + }); + + expect(res.status).toBe(200); + expect(res.body.provider.id).toBe('google'); + expect(res.body.state).toMatch(/\./); + expect(res.body.authorizationUrl).toContain('client_id=client-id'); + expect(res.body.authorizationUrl).toContain('state='); + }); + + it('finishes OAuth login and issues a SeamlessAuth session', async () => { + const start = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174/oauth/callback', + }); + + const user = buildUser({ + id: 'user-1', + email: 'person@example.com', + phone: 'oauth:google:provider-user', + roles: ['user'], + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'provider-token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sub: 'provider-user', + email: 'person@example.com', + name: 'Person Example', + }), + }); + + (OAuthIdentity.findOne as any).mockResolvedValue(null); + (OAuthIdentity.findOrCreate as any).mockResolvedValue([]); + (User.findOne as any).mockResolvedValue(user); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + (signAccessToken as any).mockResolvedValue('access-token'); + (generateRefreshToken as any).mockReturnValue('refresh-token'); + (hashRefreshToken as any).mockResolvedValue('refresh-hash'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + + const res = await request(app).post('/oauth/google/callback').send({ + code: 'oauth-code', + state: start.body.state, + }); + + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://oauth2.googleapis.com/token', + expect.objectContaining({ + method: 'POST', + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://openidconnect.googleapis.com/v1/userinfo', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer provider-token', + }), + }), + ); + expect(res.body).toEqual( + expect.objectContaining({ + message: 'Success', + token: 'access-token', + sub: 'user-1', + }), + ); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 23414dd..4a3f423 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -46,6 +46,16 @@ vi.mock('../../src/models/totpCredentials.js', () => ({ }, })); +vi.mock('../../src/models/oauthIdentities.js', () => ({ + OAuthIdentity: { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + findOrCreate: vi.fn(), + count: vi.fn(), + }, +})); + vi.mock('../../src/models/organizations.js', () => ({ Organization: { create: vi.fn(), diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 3cf6500..f7ed32d 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -7,6 +7,7 @@ vi.unmock('../../../src/models/systemConfig.js'); vi.unmock('../../../src/models/credentials.js'); vi.unmock('../../../src/models/totpCredentials.js'); vi.unmock('../../../src/models/magicLinks.js'); +vi.unmock('../../../src/models/oauthIdentities.js'); vi.unmock('../../../src/models/organizations.js'); vi.unmock('../../../src/models/organizationMemberships.js'); diff --git a/tests/unit/services/oauthService.spec.ts b/tests/unit/services/oauthService.spec.ts new file mode 100644 index 0000000..2ad432d --- /dev/null +++ b/tests/unit/services/oauthService.spec.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { OAuthIdentity } from '../../../src/models/oauthIdentities.js'; +import { User } from '../../../src/models/users.js'; +import { + buildOAuthAuthorizationUrl, + createOAuthState, + exchangeOAuthCode, + fetchOAuthProfile, + getEnabledOAuthProviders, + resolveOAuthRedirectUri, + resolveOAuthUser, + verifyOAuthState, +} from '../../../src/services/oauthService.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; +import { buildUser } from '../../factories/userFactory.js'; + +const provider = { + id: 'google', + name: 'Google', + enabled: true, + clientId: 'client-id', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + scopes: ['openid', 'email', 'profile'], + subjectJsonPath: 'sub', + emailJsonPath: 'email', + nameJsonPath: 'name', + allowSignup: true, +}; + +describe('oauthService', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('GOOGLE_CLIENT_SECRET', 'secret'); + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'oauth'], + oauth_providers: [provider], + }), + ); + }); + + it('lists only enabled providers when oauth login is enabled', async () => { + await expect(getEnabledOAuthProviders()).resolves.toEqual([provider]); + }); + + it('creates verifiable signed state values', () => { + const state = createOAuthState({ + providerId: 'google', + redirectUri: 'https://app.example.com/oauth/callback', + returnTo: 'https://app.example.com/', + }); + + expect(verifyOAuthState(state, 'google')).toEqual( + expect.objectContaining({ + providerId: 'google', + redirectUri: 'https://app.example.com/oauth/callback', + returnTo: 'https://app.example.com/', + }), + ); + expect(verifyOAuthState(state, 'github')).toBeNull(); + }); + + it('builds provider authorization URLs', () => { + const state = 'state'; + const url = buildOAuthAuthorizationUrl({ + provider, + redirectUri: 'https://app.example.com/oauth/callback', + state, + }); + + expect(url).toContain('client_id=client-id'); + expect(url).toContain('response_type=code'); + expect(url).toContain('scope=openid+email+profile'); + expect(url).toContain('state=state'); + }); + + it('rejects redirect URIs outside configured origins', async () => { + await expect( + resolveOAuthRedirectUri(provider, 'https://evil.example.com/oauth/callback'), + ).rejects.toThrow('OAuth redirect URI is not allowed'); + }); + + it('exchanges code and parses profile without exposing provider tokens', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'provider-token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sub: 'provider-user', + email: 'Person@Example.com', + name: 'Person Example', + }), + }); + + const token = await exchangeOAuthCode({ + provider, + code: 'code', + redirectUri: 'https://app.example.com/oauth/callback', + }); + const profile = await fetchOAuthProfile(provider, token); + + expect(token).toBe('provider-token'); + expect(profile).toEqual({ + subject: 'provider-user', + email: 'person@example.com', + name: 'Person Example', + raw: { + sub: 'provider-user', + email: 'Person@Example.com', + name: 'Person Example', + }, + }); + }); + + it('links an OAuth profile to an existing user', async () => { + const user = buildUser({ id: 'user-1', email: 'person@example.com' }); + + (OAuthIdentity.findOne as any).mockResolvedValue(null); + (User.findOne as any).mockResolvedValue(user); + (OAuthIdentity.findOrCreate as any).mockResolvedValue([]); + + await expect( + resolveOAuthUser(provider, { + subject: 'provider-user', + email: 'person@example.com', + name: 'Person Example', + raw: {}, + }), + ).resolves.toBe(user); + + expect(OAuthIdentity.findOrCreate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + providerId: 'google', + providerSubject: 'provider-user', + }, + defaults: expect.objectContaining({ + userId: 'user-1', + email: 'person@example.com', + }), + }), + ); + }); +}); From 448e876398e86aa525bbd073059f98a63720632a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 17:48:54 -0400 Subject: [PATCH 2/2] ci: linting --- src/routes/oauth.routes.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/routes/oauth.routes.ts b/src/routes/oauth.routes.ts index 51d92f9..751379f 100644 --- a/src/routes/oauth.routes.ts +++ b/src/routes/oauth.routes.ts @@ -4,11 +4,7 @@ * See LICENSE file in the project root for full license information */ -import { - finishOAuthLogin, - listOAuthProviders, - startOAuthLogin, -} from '../controllers/oauth.js'; +import { finishOAuthLogin, listOAuthProviders, startOAuthLogin } from '../controllers/oauth.js'; import { createRouter } from '../lib/createRouter.js'; import { FinishOAuthLoginRequestSchema,