Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
1 change: 1 addition & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ import type { SystemConfig } from '../schemas/systemConfig.schema.js';

export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
login_methods: ['passkey', 'magic_link'],
oauth_providers: [],
passkey_login_fallback_enabled: true,
};
1 change: 1 addition & 0 deletions src/config/systemConfig.envMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
154 changes: 154 additions & 0 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
}
77 changes: 77 additions & 0 deletions src/migrations/20260519100000-add-oauth-support.cjs
Original file line number Diff line number Diff line change
@@ -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');
},
};
Loading
Loading