diff --git a/packages/oauth/README.md b/packages/oauth/README.md
index f8c6866041..e38b082639 100644
--- a/packages/oauth/README.md
+++ b/packages/oauth/README.md
@@ -1,128 +1,69 @@
# @constructive-io/oauth
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-> Minimal OAuth 2.0 client for social authentication
-
-A lightweight OAuth 2.0 client for social authentication with Google, GitHub, Facebook, and LinkedIn. Uses [`@constructive-io/csrf`](../csrf) for secure state management. No external auth library dependencies - uses native fetch for HTTP requests.
-
-## Installation
-
-```bash
-pnpm add @constructive-io/oauth
-```
-
-## Usage
-
-### Basic Setup
-
-```typescript
-import { createOAuthClient } from '@constructive-io/oauth';
-
-const client = createOAuthClient({
- providers: {
- google: {
- clientId: process.env.GOOGLE_CLIENT_ID,
- clientSecret: process.env.GOOGLE_CLIENT_SECRET,
- },
- github: {
- clientId: process.env.GITHUB_CLIENT_ID,
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
- },
- },
- baseUrl: 'https://api.example.com',
-});
-
-// Generate authorization URL
-const { url, state } = client.getAuthorizationUrl({ provider: 'google' });
-
-// After user authorizes, exchange code for profile
-const profile = await client.handleCallback({ provider: 'google', code });
-```
-
-### Express Middleware
-
-```typescript
-import express from 'express';
-import cookieParser from 'cookie-parser';
-import { createOAuthMiddleware } from '@constructive-io/oauth';
-
-const app = express();
-app.use(cookieParser());
-
-const oauth = createOAuthMiddleware({
- providers: {
- google: { clientId: '...', clientSecret: '...' },
- github: { clientId: '...', clientSecret: '...' },
- facebook: { clientId: '...', clientSecret: '...' },
- linkedin: { clientId: '...', clientSecret: '...' },
- },
- baseUrl: 'https://api.example.com',
- onSuccess: async (profile, context) => {
- // Handle successful authentication
- // Create/update user in database, generate session token, etc.
- return { user: profile };
- },
- onError: (error, context) => {
- console.error('OAuth error:', error);
- },
- successRedirect: 'https://app.example.com/dashboard',
- errorRedirect: 'https://app.example.com/login?error=auth_failed',
+Protocol primitives and Provider adapters for Constructive OAuth/OIDC sign-in.
+
+The package owns:
+
+- cryptographically random OAuth state, OIDC nonce, and RFC 7636 S256 PKCE;
+- authorization URL construction with protected parameters;
+- exact Provider endpoint allowlists and bounded, no-redirect JSON requests;
+- a protocol-neutral `ProviderAdapter` contract;
+- registered Google/OIDC and GitHub/OAuth adapter implementations; and
+- safe normalized external identities without raw Provider payloads or tokens.
+
+It deliberately does not own Express routes, Cookies, Tenant resolution,
+database state, account association, Constructive credentials, or Site handoff.
+Those remain in their Constructive orchestration owners.
+
+## Security model
+
+Every Provider flow uses Authorization Code with S256 PKCE. Constructive creates
+and persists the state, verifier, and optional nonce before navigation. Only the
+state and S256 challenge reach the browser. The callback supplies the code to
+Constructive, and the selected adapter exchanges it with the original
+server-held verifier.
+
+Provider configuration is supplied by the caller after Tenant-scoped loader
+resolution. Adapters do not read environment variables or databases. Endpoints
+must match the concrete adapter's HTTPS allowlist, requests reject redirects,
+and Provider response bodies are never included in errors.
+
+## Example
+
+```ts
+import {
+ deriveS256CodeChallenge,
+ generateCodeVerifier,
+ generateOidcNonce,
+ generateOpaqueState,
+ getProviderAdapter
+} from '@constructive-io/oauth';
+
+const adapter = getProviderAdapter(provider.slug);
+const config = adapter.validateConfiguration(provider);
+const state = generateOpaqueState();
+const codeVerifier = generateCodeVerifier();
+const nonce = adapter.kind === 'google' ? generateOidcNonce() : undefined;
+
+const { url } = adapter.createAuthorizationRequest({
+ config,
+ redirectUri,
+ state,
+ codeChallenge: deriveS256CodeChallenge(codeVerifier),
+ nonce
});
-
-// Mount routes
-app.get('/auth/:provider', oauth.initiateAuth);
-app.get('/auth/:provider/callback', oauth.handleCallback);
-app.get('/auth/providers', oauth.getProviders);
```
-## Supported Providers
+The common service persists the request artifacts before using `url`. It later
+calls `completeAuthorization` with the callback code and server-held artifacts,
+then consumes only the returned `NormalizedExternalIdentity`.
-| Provider | Scopes |
-|----------|--------|
-| Google | `openid`, `email`, `profile` |
-| GitHub | `user:email`, `read:user` |
-| Facebook | `email`, `public_profile` |
-| LinkedIn | `openid`, `profile`, `email` |
+## Legacy surface
-## API
-
-### `createOAuthClient(config)`
-
-Creates an OAuth client instance.
-
-### `createOAuthMiddleware(config)`
-
-Creates Express route handlers for OAuth flows.
-
-### `OAuthProfile`
-
-The normalized user profile returned after authentication:
-
-```typescript
-interface OAuthProfile {
- provider: string; // 'google', 'github', etc.
- providerId: string; // Provider's unique user ID
- email: string | null;
- name: string | null;
- picture: string | null;
- raw: unknown; // Original provider response
-}
-```
+The previous hard-coded Provider registry, Express middleware,
+`/auth/providers` discovery handler, browser state Cookie, and raw profile
+payload are intentionally not part of this API. Provider discovery belongs to
+Tenant-scoped GraphQL, and replay protection belongs to persisted server state.
## License
diff --git a/packages/oauth/__tests__/adapters.test.ts b/packages/oauth/__tests__/adapters.test.ts
new file mode 100644
index 0000000000..b9d65b6e74
--- /dev/null
+++ b/packages/oauth/__tests__/adapters.test.ts
@@ -0,0 +1,236 @@
+import {
+ exportJWK,
+ generateKeyPair,
+ type KeyLike,
+ SignJWT} from 'jose';
+
+import {
+ getProviderAdapter,
+ getProviderAdapterKinds,
+ githubAdapter,
+ googleAdapter,
+ type IdentityProviderConfiguration,
+ ProviderAdapterError} from '../src';
+
+const providerConfig = (
+ overrides: Partial
+): IdentityProviderConfiguration => ({
+ slug: 'provider',
+ kind: 'oauth2',
+ displayName: 'Provider',
+ enabled: true,
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ authorizationUrl: null,
+ tokenUrl: null,
+ userinfoUrl: null,
+ issuerUrl: null,
+ discoveryDoc: null,
+ jwks: null,
+ acceptableClientIds: [],
+ scopes: [],
+ extraAuthorizationParams: {},
+ emailOptional: true,
+ skipNonceCheck: false,
+ pkceEnabled: true,
+ ...overrides
+});
+
+const jsonResponse = (value: unknown): Response =>
+ new Response(JSON.stringify(value), {
+ headers: { 'content-type': 'application/json' }
+ });
+
+describe('Provider adapter registry', () => {
+ it('registers Google and GitHub without a Provider-specific workflow API', () => {
+ expect(getProviderAdapterKinds()).toEqual(['google', 'github']);
+ expect(getProviderAdapter('google')).toBe(googleAdapter);
+ expect(getProviderAdapter('github')).toBe(githubAdapter);
+ expect(() => getProviderAdapter('not-registered')).toThrow(
+ ProviderAdapterError
+ );
+ });
+});
+
+describe('Google OIDC adapter', () => {
+ let privateKey: KeyLike;
+ let publicJwk: Awaited>;
+
+ beforeAll(async () => {
+ const pair = await generateKeyPair('RS256');
+ privateKey = pair.privateKey;
+ publicJwk = await exportJWK(pair.publicKey);
+ publicJwk.kid = 'test-key';
+ publicJwk.alg = 'RS256';
+ });
+
+ const googleConfig = (): IdentityProviderConfiguration =>
+ providerConfig({
+ slug: 'google',
+ kind: 'oidc',
+ displayName: 'Google',
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ issuerUrl: 'https://accounts.google.com',
+ jwks: { keys: [publicJwk] },
+ scopes: ['openid', 'email', 'profile']
+ });
+
+ it('builds mandatory S256 and nonce authorization parameters', () => {
+ const config = googleAdapter.validateConfiguration(googleConfig());
+ const url = new URL(
+ googleAdapter.createAuthorizationRequest({
+ config,
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ state: 's'.repeat(43),
+ codeChallenge: 'c'.repeat(43),
+ nonce: 'n'.repeat(43)
+ }).url
+ );
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({
+ code_challenge: 'c'.repeat(43),
+ code_challenge_method: 'S256',
+ nonce: 'n'.repeat(43),
+ state: 's'.repeat(43)
+ });
+ });
+
+ it('verifies the ID token and returns only normalized identity data', async () => {
+ const idToken = await new SignJWT({
+ email: 'person@example.com',
+ email_verified: false,
+ name: 'Example Person',
+ nonce: 'n'.repeat(43),
+ picture: 'https://images.example.com/person.png'
+ })
+ .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
+ .setIssuer('https://accounts.google.com')
+ .setAudience('client-id')
+ .setSubject('google-subject')
+ .setIssuedAt()
+ .setExpirationTime('5m')
+ .sign(privateKey);
+ const fetchMock = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ jsonResponse({ access_token: 'never-return-this', id_token: idToken })
+ ) as unknown as jest.MockedFunction;
+
+ const identity = await googleAdapter.completeAuthorization({
+ config: googleAdapter.validateConfiguration(googleConfig()),
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ code: 'authorization-code',
+ codeVerifier: 'a'.repeat(43),
+ nonce: 'n'.repeat(43),
+ requestTimeoutMs: 1000,
+ fetch: fetchMock
+ });
+
+ expect(identity).toEqual({
+ providerKey: 'google',
+ subject: 'google-subject',
+ email: 'person@example.com',
+ profile: {
+ name: 'Example Person',
+ avatarUrl: 'https://images.example.com/person.png',
+ emailVerified: false
+ }
+ });
+ expect(JSON.stringify(identity)).not.toContain('never-return-this');
+ });
+
+ it('fails closed when PKCE or nonce verification is disabled', () => {
+ expect(() =>
+ googleAdapter.validateConfiguration(
+ { ...googleConfig(), pkceEnabled: false }
+ )
+ ).toThrow(ProviderAdapterError);
+ expect(() =>
+ googleAdapter.validateConfiguration(
+ { ...googleConfig(), skipNonceCheck: true }
+ )
+ ).toThrow(ProviderAdapterError);
+ });
+
+ it('rejects an ID token that is not bound to the original nonce', async () => {
+ const idToken = await new SignJWT({ nonce: 'different-nonce' })
+ .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
+ .setIssuer('https://accounts.google.com')
+ .setAudience('client-id')
+ .setSubject('google-subject')
+ .setIssuedAt()
+ .setExpirationTime('5m')
+ .sign(privateKey);
+ const fetchMock = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ jsonResponse({ id_token: idToken })
+ ) as unknown as jest.MockedFunction;
+
+ await expect(
+ googleAdapter.completeAuthorization({
+ config: googleAdapter.validateConfiguration(googleConfig()),
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ code: 'authorization-code',
+ codeVerifier: 'a'.repeat(43),
+ nonce: 'n'.repeat(43),
+ requestTimeoutMs: 1000,
+ fetch: fetchMock
+ })
+ ).rejects.toMatchObject({ reason: 'IDENTITY_VERIFICATION_FAILED' });
+ });
+});
+
+describe('GitHub OAuth adapter', () => {
+ const githubConfig = (): IdentityProviderConfiguration =>
+ providerConfig({
+ slug: 'github',
+ displayName: 'GitHub',
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
+ tokenUrl: 'https://github.com/login/oauth/access_token',
+ userinfoUrl: 'https://api.github.com/user',
+ scopes: ['read:user', 'user:email']
+ });
+
+ it('uses server-only access token for profile/email and normalizes stable ID', async () => {
+ const responses: unknown[] = [
+ { access_token: 'github-server-token' },
+ {
+ id: 42,
+ login: 'octocat',
+ name: 'Octo Cat',
+ email: null,
+ avatar_url: 'https://avatars.githubusercontent.com/u/42'
+ },
+ [{ email: 'octo@example.com', primary: true, verified: true }]
+ ];
+ const fetchMock = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ jsonResponse(responses.shift())
+ ) as unknown as jest.MockedFunction;
+
+ const identity = await githubAdapter.completeAuthorization({
+ config: githubAdapter.validateConfiguration(githubConfig()),
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ code: 'authorization-code',
+ codeVerifier: 'b'.repeat(43),
+ requestTimeoutMs: 1000,
+ fetch: fetchMock
+ });
+
+ expect(identity).toEqual({
+ providerKey: 'github',
+ subject: '42',
+ email: 'octo@example.com',
+ profile: {
+ name: 'Octo Cat',
+ username: 'octocat',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/42',
+ emailVerified: true
+ }
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
+ Authorization: 'Bearer github-server-token'
+ });
+ expect(JSON.stringify(identity)).not.toContain('github-server-token');
+ });
+});
diff --git a/packages/oauth/__tests__/http.test.ts b/packages/oauth/__tests__/http.test.ts
new file mode 100644
index 0000000000..b66249d887
--- /dev/null
+++ b/packages/oauth/__tests__/http.test.ts
@@ -0,0 +1,91 @@
+import {
+ ProviderAdapterError,
+ requestProviderJson,
+ validateProviderEndpoint
+} from '../src';
+
+const endpoint = validateProviderEndpoint('https://api.example.com/token', [
+ 'https://api.example.com/token'
+]);
+
+describe('bounded Provider requests', () => {
+ it('uses no-redirect fetch and parses a bounded JSON response', async () => {
+ const fetchMock = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ new Response(JSON.stringify({ ok: true }), {
+ headers: { 'content-type': 'application/json' }
+ })
+ ) as unknown as jest.MockedFunction;
+
+ await expect(
+ requestProviderJson(endpoint, { method: 'POST' }, {
+ timeoutMs: 1000,
+ fetch: fetchMock
+ })
+ ).resolves.toEqual({ ok: true });
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({
+ method: 'POST',
+ redirect: 'error'
+ });
+ });
+
+ it('does not expose an unsuccessful Provider response body', async () => {
+ const secretBody = 'provider-secret-response';
+ const fetchMock = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ new Response(secretBody, {
+ status: 400,
+ headers: { 'content-type': 'application/json' }
+ })
+ ) as unknown as jest.MockedFunction;
+
+ const error = (await requestProviderJson(endpoint, {}, {
+ timeoutMs: 1000,
+ fetch: fetchMock
+ }).catch(value => value)) as ProviderAdapterError;
+ expect(error).toBeInstanceOf(ProviderAdapterError);
+ expect(error.reason).toBe('INVALID_RESPONSE');
+ expect(error.message).not.toContain(secretBody);
+ });
+
+ it('rejects oversized and non-JSON responses', async () => {
+ const oversized = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ new Response('{}', {
+ headers: {
+ 'content-length': '70000',
+ 'content-type': 'application/json'
+ }
+ })
+ ) as unknown as jest.MockedFunction;
+ await expect(
+ requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: oversized })
+ ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' });
+
+ const wrongType = jest.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ new Response('{}', { headers: { 'content-type': 'text/plain' } })
+ ) as unknown as jest.MockedFunction;
+ await expect(
+ requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: wrongType })
+ ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' });
+ });
+
+ it('classifies timeout without swallowing its cause', async () => {
+ const fetchMock = jest.fn(
+ (_input: RequestInfo | URL, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () =>
+ reject(new DOMException('aborted', 'AbortError'))
+ );
+ })
+ ) as unknown as jest.MockedFunction;
+
+ const error = (await requestProviderJson(endpoint, {}, {
+ timeoutMs: 1,
+ fetch: fetchMock
+ }).catch(value => value)) as ProviderAdapterError;
+ expect(error).toMatchObject({ reason: 'REQUEST_TIMEOUT' });
+ expect(error.cause).toBeInstanceOf(DOMException);
+ });
+});
diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts
deleted file mode 100644
index 541192de35..0000000000
--- a/packages/oauth/__tests__/oauth-client.test.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import { createOAuthClient } from '../src/oauth-client';
-import { getProvider, getProviderIds } from '../src/providers';
-import { generateState, verifyState } from '../src/utils/state';
-
-describe('OAuthClient', () => {
- const config = {
- providers: {
- google: {
- clientId: 'test-google-client-id',
- clientSecret: 'test-google-client-secret',
- },
- github: {
- clientId: 'test-github-client-id',
- clientSecret: 'test-github-client-secret',
- },
- },
- baseUrl: 'https://api.example.com',
- };
-
- describe('getAuthorizationUrl', () => {
- it('should generate authorization URL for Google', () => {
- const client = createOAuthClient(config);
- const { url, state } = client.getAuthorizationUrl({ provider: 'google' });
-
- expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth');
- expect(url).toContain('client_id=test-google-client-id');
- expect(url).toContain('redirect_uri=');
- expect(url).toContain('response_type=code');
- expect(url).toContain('scope=openid+email+profile');
- expect(url).toContain(`state=${state}`);
- expect(state).toHaveLength(64);
- });
-
- it('should generate authorization URL for GitHub', () => {
- const client = createOAuthClient(config);
- const { url, state } = client.getAuthorizationUrl({ provider: 'github' });
-
- expect(url).toContain('https://github.com/login/oauth/authorize');
- expect(url).toContain('client_id=test-github-client-id');
- expect(url).toContain('scope=user%3Aemail+read%3Auser');
- expect(state).toHaveLength(64);
- });
-
- it('should use custom state when provided', () => {
- const client = createOAuthClient(config);
- const customState = 'my-custom-state-123';
- const { url, state } = client.getAuthorizationUrl({
- provider: 'google',
- state: customState,
- });
-
- expect(state).toBe(customState);
- expect(url).toContain(`state=${customState}`);
- });
-
- it('should use custom redirect URI when provided', () => {
- const client = createOAuthClient(config);
- const customRedirectUri = 'https://custom.example.com/callback';
- const { url } = client.getAuthorizationUrl({
- provider: 'google',
- redirectUri: customRedirectUri,
- });
-
- expect(url).toContain(`redirect_uri=${encodeURIComponent(customRedirectUri)}`);
- });
-
- it('should use custom scopes when provided', () => {
- const client = createOAuthClient(config);
- const { url } = client.getAuthorizationUrl({
- provider: 'google',
- scopes: ['email'],
- });
-
- expect(url).toContain('scope=email');
- expect(url).not.toContain('profile');
- });
-
- it('should throw error for unknown provider', () => {
- const client = createOAuthClient(config);
-
- expect(() => {
- client.getAuthorizationUrl({ provider: 'unknown' });
- }).toThrow('Unknown provider: unknown');
- });
-
- it('should throw error for unconfigured provider', () => {
- const client = createOAuthClient(config);
-
- expect(() => {
- client.getAuthorizationUrl({ provider: 'facebook' });
- }).toThrow('No credentials configured for provider: facebook');
- });
- });
-
- describe('getConfig', () => {
- it('should return config with defaults', () => {
- const client = createOAuthClient(config);
- const returnedConfig = client.getConfig();
-
- expect(returnedConfig.callbackPath).toBe('/auth/{provider}/callback');
- expect(returnedConfig.stateCookieName).toBe('oauth_state');
- expect(returnedConfig.stateCookieMaxAge).toBe(600);
- });
-
- it('should allow overriding defaults', () => {
- const client = createOAuthClient({
- ...config,
- callbackPath: '/custom/callback/{provider}',
- stateCookieName: 'custom_state',
- stateCookieMaxAge: 300,
- });
- const returnedConfig = client.getConfig();
-
- expect(returnedConfig.callbackPath).toBe('/custom/callback/{provider}');
- expect(returnedConfig.stateCookieName).toBe('custom_state');
- expect(returnedConfig.stateCookieMaxAge).toBe(300);
- });
- });
-});
-
-describe('providers', () => {
- it('should have all expected providers', () => {
- const ids = getProviderIds();
- expect(ids).toContain('google');
- expect(ids).toContain('github');
- expect(ids).toContain('facebook');
- expect(ids).toContain('linkedin');
- });
-
- it('should return provider config by id', () => {
- const google = getProvider('google');
- expect(google).toBeDefined();
- expect(google!.id).toBe('google');
- expect(google!.name).toBe('Google');
- expect(google!.authorizationUrl).toBe('https://accounts.google.com/o/oauth2/v2/auth');
- });
-
- it('should return undefined for unknown provider', () => {
- const unknown = getProvider('unknown');
- expect(unknown).toBeUndefined();
- });
-});
-
-describe('state utilities', () => {
- describe('generateState', () => {
- it('should generate random state of default length', () => {
- const state = generateState();
- expect(state).toHaveLength(64);
- });
-
- it('should generate random state of custom length', () => {
- const state = generateState(16);
- expect(state).toHaveLength(32);
- });
-
- it('should generate unique states', () => {
- const state1 = generateState();
- const state2 = generateState();
- expect(state1).not.toBe(state2);
- });
- });
-
- describe('verifyState', () => {
- it('should return true for matching states', () => {
- const state = generateState();
- expect(verifyState(state, state)).toBe(true);
- });
-
- it('should return false for non-matching states', () => {
- const state1 = generateState();
- const state2 = generateState();
- expect(verifyState(state1, state2)).toBe(false);
- });
-
- it('should return false for undefined expected state', () => {
- expect(verifyState(undefined, 'some-state')).toBe(false);
- });
-
- it('should return false for undefined actual state', () => {
- expect(verifyState('some-state', undefined)).toBe(false);
- });
-
- it('should return false for different length states', () => {
- expect(verifyState('short', 'much-longer-state')).toBe(false);
- });
- });
-});
-
-describe('provider profile mapping', () => {
- it('should map Google profile correctly', () => {
- const google = getProvider('google')!;
- const profile = google.mapProfile({
- sub: '123456789',
- email: 'test@gmail.com',
- name: 'Test User',
- picture: 'https://example.com/photo.jpg',
- });
-
- expect(profile.provider).toBe('google');
- expect(profile.providerId).toBe('123456789');
- expect(profile.email).toBe('test@gmail.com');
- expect(profile.name).toBe('Test User');
- expect(profile.picture).toBe('https://example.com/photo.jpg');
- });
-
- it('should map GitHub profile correctly', () => {
- const github = getProvider('github')!;
- const profile = github.mapProfile({
- id: 12345,
- login: 'testuser',
- name: 'Test User',
- email: 'test@github.com',
- avatar_url: 'https://avatars.githubusercontent.com/u/12345',
- });
-
- expect(profile.provider).toBe('github');
- expect(profile.providerId).toBe('12345');
- expect(profile.email).toBe('test@github.com');
- expect(profile.name).toBe('Test User');
- expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345');
- });
-
- it('should map Facebook profile correctly', () => {
- const facebook = getProvider('facebook')!;
- const profile = facebook.mapProfile({
- id: '987654321',
- name: 'Test User',
- email: 'test@facebook.com',
- picture: { data: { url: 'https://example.com/fb-photo.jpg' } },
- });
-
- expect(profile.provider).toBe('facebook');
- expect(profile.providerId).toBe('987654321');
- expect(profile.email).toBe('test@facebook.com');
- expect(profile.name).toBe('Test User');
- expect(profile.picture).toBe('https://example.com/fb-photo.jpg');
- });
-
- it('should map LinkedIn profile correctly', () => {
- const linkedin = getProvider('linkedin')!;
- const profile = linkedin.mapProfile({
- sub: 'linkedin-123',
- email: 'test@linkedin.com',
- name: 'Test User',
- picture: 'https://example.com/li-photo.jpg',
- });
-
- expect(profile.provider).toBe('linkedin');
- expect(profile.providerId).toBe('linkedin-123');
- expect(profile.email).toBe('test@linkedin.com');
- expect(profile.name).toBe('Test User');
- expect(profile.picture).toBe('https://example.com/li-photo.jpg');
- });
-
- it('should handle missing optional fields', () => {
- const google = getProvider('google')!;
- const profile = google.mapProfile({
- sub: '123456789',
- });
-
- expect(profile.provider).toBe('google');
- expect(profile.providerId).toBe('123456789');
- expect(profile.email).toBeNull();
- expect(profile.name).toBeNull();
- expect(profile.picture).toBeNull();
- });
-});
diff --git a/packages/oauth/__tests__/primitives.test.ts b/packages/oauth/__tests__/primitives.test.ts
new file mode 100644
index 0000000000..7af99eab24
--- /dev/null
+++ b/packages/oauth/__tests__/primitives.test.ts
@@ -0,0 +1,102 @@
+import {
+ createAuthorizationUrl,
+ deriveS256CodeChallenge,
+ generateCodeVerifier,
+ generateOidcNonce,
+ generateOpaqueState,
+ isOpaqueOAuthValue,
+ ProviderAdapterError,
+ validateProviderEndpoint
+} from '../src';
+
+describe('OAuth protocol primitives', () => {
+ it('generates unique 32-byte browser-safe values', () => {
+ const values = [
+ generateOpaqueState(),
+ generateOpaqueState(),
+ generateCodeVerifier(),
+ generateOidcNonce()
+ ];
+ expect(new Set(values).size).toBe(values.length);
+ for (const value of values) {
+ expect(value).toHaveLength(43);
+ expect(isOpaqueOAuthValue(value)).toBe(true);
+ expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
+ }
+ });
+
+ it('derives the RFC 7636 S256 example challenge', () => {
+ expect(
+ deriveS256CodeChallenge(
+ 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
+ )
+ ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
+ });
+
+ it('rejects a verifier outside the RFC 7636 shape', () => {
+ expect(() => deriveS256CodeChallenge('too-short')).toThrow(
+ ProviderAdapterError
+ );
+ });
+});
+
+describe('Provider endpoint and authorization URL safety', () => {
+ const endpoint = validateProviderEndpoint(
+ 'https://accounts.example.com/oauth/authorize',
+ ['https://accounts.example.com/oauth/authorize']
+ );
+
+ it('requires an exact, clean, allowlisted HTTPS endpoint', () => {
+ for (const unsafe of [
+ 'http://accounts.example.com/oauth/authorize',
+ 'https://accounts.example.com/oauth/authorize?next=unsafe',
+ 'https://user:secret@accounts.example.com/oauth/authorize',
+ 'https://127.0.0.1/oauth/authorize',
+ 'https://[::1]/oauth/authorize',
+ 'https://accounts.example.com/other'
+ ]) {
+ expect(() =>
+ validateProviderEndpoint(unsafe, [
+ 'https://accounts.example.com/oauth/authorize'
+ ])
+ ).toThrow(ProviderAdapterError);
+ }
+ });
+
+ it('owns all security-sensitive authorization parameters', () => {
+ expect(() =>
+ createAuthorizationUrl({
+ endpoint,
+ clientId: 'client-id',
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ scopes: ['openid'],
+ state: 's'.repeat(43),
+ codeChallenge: 'c'.repeat(43),
+ extraParameters: { prompt: 'select_account', state: 'overridden' }
+ })
+ ).toThrow(/owned by the OAuth flow/);
+
+ const url = new URL(
+ createAuthorizationUrl({
+ endpoint,
+ clientId: 'client-id',
+ redirectUri: 'https://auth.example.com/auth/oauth/callback',
+ scopes: ['openid', 'email'],
+ state: 's'.repeat(43),
+ codeChallenge: 'c'.repeat(43),
+ nonce: 'n'.repeat(43),
+ extraParameters: { prompt: 'select_account' }
+ })
+ );
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({
+ client_id: 'client-id',
+ code_challenge: 'c'.repeat(43),
+ code_challenge_method: 'S256',
+ nonce: 'n'.repeat(43),
+ prompt: 'select_account',
+ response_type: 'code',
+ scope: 'openid email',
+ state: 's'.repeat(43)
+ });
+ });
+});
diff --git a/packages/oauth/jest.config.js b/packages/oauth/jest.config.js
index 047b2ae4ee..610b872561 100644
--- a/packages/oauth/jest.config.js
+++ b/packages/oauth/jest.config.js
@@ -4,5 +4,6 @@ module.exports = {
'^.+\\.tsx?$': ['ts-jest', { useESM: false }],
},
testMatch: ['**/__tests__/**/*.test.ts'],
+ modulePathIgnorePatterns: ['/dist/'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};
diff --git a/packages/oauth/package.json b/packages/oauth/package.json
index 2cfd50f3c0..7ddb16fa0f 100644
--- a/packages/oauth/package.json
+++ b/packages/oauth/package.json
@@ -2,7 +2,7 @@
"name": "@constructive-io/oauth",
"version": "0.27.0",
"author": "Constructive ",
- "description": "OAuth 2.0 client for social authentication (Google, GitHub, Facebook, LinkedIn)",
+ "description": "OAuth/OIDC protocol primitives and Provider adapters for Constructive authentication",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
@@ -29,7 +29,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
- "@constructive-io/csrf": "workspace:^"
+ "jose": "^5.10.0"
},
"devDependencies": {
"@types/node": "^22.19.11",
@@ -42,9 +42,9 @@
"authentication",
"google",
"github",
- "facebook",
- "linkedin",
- "social-login",
+ "oidc",
+ "pkce",
+ "provider-adapter",
"constructive"
]
}
diff --git a/packages/oauth/src/adapter.ts b/packages/oauth/src/adapter.ts
new file mode 100644
index 0000000000..c61bccdea1
--- /dev/null
+++ b/packages/oauth/src/adapter.ts
@@ -0,0 +1,25 @@
+import type {
+ IdentityProviderConfiguration,
+ NormalizedExternalIdentity,
+ ProviderAuthorizationInput,
+ ProviderAuthorizationResult,
+ ProviderCallbackInput,
+ ValidatedProviderConfiguration
+} from './types';
+
+/**
+ * Protocol-neutral Provider boundary. Common login orchestration remains
+ * outside adapters and no inheritance hierarchy is required.
+ */
+export interface ProviderAdapter<
+ C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration
+> {
+ readonly kind: string;
+ validateConfiguration(input: IdentityProviderConfiguration): C;
+ createAuthorizationRequest(
+ input: ProviderAuthorizationInput
+ ): ProviderAuthorizationResult;
+ completeAuthorization(
+ input: ProviderCallbackInput
+ ): Promise;
+}
diff --git a/packages/oauth/src/authorization.ts b/packages/oauth/src/authorization.ts
new file mode 100644
index 0000000000..a85e10fac6
--- /dev/null
+++ b/packages/oauth/src/authorization.ts
@@ -0,0 +1,86 @@
+import { isOpaqueOAuthValue } from './primitives';
+import type { ValidatedEndpoint } from './types';
+import { ProviderAdapterError } from './types';
+
+const PROTECTED_PARAMETERS = new Set([
+ 'client_id',
+ 'code_challenge',
+ 'code_challenge_method',
+ 'nonce',
+ 'redirect_uri',
+ 'response_type',
+ 'scope',
+ 'state'
+]);
+
+export interface AuthorizationUrlInput {
+ endpoint: ValidatedEndpoint;
+ clientId: string;
+ redirectUri: string;
+ scopes: readonly string[];
+ state: string;
+ codeChallenge: string;
+ nonce?: string;
+ extraParameters?: Readonly>;
+}
+
+export const validateProviderCallbackUri = (value: string): string => {
+ let redirectUri: URL;
+ try {
+ redirectUri = new URL(value);
+ } catch (cause) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'The Provider callback URI is invalid.',
+ { cause }
+ );
+ }
+ if (
+ redirectUri.protocol !== 'https:' ||
+ redirectUri.username ||
+ redirectUri.password ||
+ redirectUri.hash
+ ) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'The Provider callback URI is invalid.'
+ );
+ }
+ return redirectUri.toString();
+};
+
+export const createAuthorizationUrl = (input: AuthorizationUrlInput): string => {
+ const redirectUri = validateProviderCallbackUri(input.redirectUri);
+ if (
+ !isOpaqueOAuthValue(input.state) ||
+ !isOpaqueOAuthValue(input.codeChallenge) ||
+ (input.nonce !== undefined && !isOpaqueOAuthValue(input.nonce))
+ ) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'The Provider authorization input is invalid.'
+ );
+ }
+
+ const url = new URL(input.endpoint);
+ for (const [key, value] of Object.entries(input.extraParameters ?? {})) {
+ if (PROTECTED_PARAMETERS.has(key.toLowerCase())) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ `The Provider parameter "${key}" is owned by the OAuth flow.`
+ );
+ }
+ url.searchParams.set(key, value);
+ }
+
+ url.searchParams.set('client_id', input.clientId);
+ url.searchParams.set('redirect_uri', redirectUri);
+ url.searchParams.set('response_type', 'code');
+ url.searchParams.set('scope', input.scopes.join(' '));
+ url.searchParams.set('state', input.state);
+ url.searchParams.set('code_challenge', input.codeChallenge);
+ url.searchParams.set('code_challenge_method', 'S256');
+ if (input.nonce) url.searchParams.set('nonce', input.nonce);
+
+ return url.toString();
+};
diff --git a/packages/oauth/src/endpoint.ts b/packages/oauth/src/endpoint.ts
new file mode 100644
index 0000000000..44915ca026
--- /dev/null
+++ b/packages/oauth/src/endpoint.ts
@@ -0,0 +1,102 @@
+import { isIP } from 'net';
+
+import { ProviderAdapterError, type ValidatedEndpoint } from './types';
+
+const isUnsafeIpv4 = (hostname: string): boolean => {
+ const octets = hostname.split('.').map(Number);
+ if (octets.length !== 4 || octets.some(value => !Number.isInteger(value))) {
+ return true;
+ }
+ const [a, b] = octets;
+ return (
+ a === 0 ||
+ a === 10 ||
+ a === 127 ||
+ (a === 100 && b >= 64 && b <= 127) ||
+ (a === 169 && b === 254) ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 0) ||
+ (a === 192 && b === 168) ||
+ (a === 198 && (b === 18 || b === 19)) ||
+ a >= 224
+ );
+};
+
+const isUnsafeIpv6 = (hostname: string): boolean => {
+ const value = hostname.toLowerCase();
+ return (
+ value === '::' ||
+ value === '::1' ||
+ value.startsWith('fc') ||
+ value.startsWith('fd') ||
+ /^fe[89ab]/.test(value) ||
+ value.startsWith('ff')
+ );
+};
+
+const isUnsafeHostname = (hostname: string): boolean => {
+ const normalized = hostname
+ .toLowerCase()
+ .replace(/^\[/, '')
+ .replace(/\]$/, '')
+ .replace(/\.$/, '');
+ if (normalized === 'localhost' || normalized.endsWith('.localhost')) {
+ return true;
+ }
+ const family = isIP(normalized);
+ return family === 4
+ ? isUnsafeIpv4(normalized)
+ : family === 6
+ ? isUnsafeIpv6(normalized)
+ : false;
+};
+
+const canonicalEndpoint = (value: string): string => {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch (cause) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Provider endpoint is not a valid URL.',
+ { cause }
+ );
+ }
+
+ if (
+ url.protocol !== 'https:' ||
+ url.username ||
+ url.password ||
+ url.search ||
+ url.hash ||
+ isUnsafeHostname(url.hostname)
+ ) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Provider endpoint is not an approved HTTPS URL.'
+ );
+ }
+ return url.toString();
+};
+
+/** Validate one configured endpoint against a concrete adapter's exact list. */
+export const validateProviderEndpoint = (
+ value: string | null | undefined,
+ allowed: readonly string[]
+): ValidatedEndpoint => {
+ if (!value) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'A required Provider endpoint is not configured.'
+ );
+ }
+ const endpoint = canonicalEndpoint(value);
+ const allowlist = allowed.map(canonicalEndpoint);
+ if (!allowlist.includes(endpoint)) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The configured Provider endpoint is not supported.'
+ );
+ }
+ return endpoint as ValidatedEndpoint;
+};
diff --git a/packages/oauth/src/http.ts b/packages/oauth/src/http.ts
new file mode 100644
index 0000000000..cccec0f108
--- /dev/null
+++ b/packages/oauth/src/http.ts
@@ -0,0 +1,118 @@
+import {
+ ProviderAdapterError,
+ type ValidatedEndpoint
+} from './types';
+
+const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
+
+export interface ProviderJsonRequestOptions {
+ timeoutMs: number;
+ fetch?: typeof fetch;
+ maxResponseBytes?: number;
+}
+
+const readBoundedBody = async (
+ response: Response,
+ maxBytes: number
+): Promise => {
+ const contentLength = response.headers.get('content-length');
+ if (contentLength && Number(contentLength) > maxBytes) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider response exceeded the allowed size.'
+ );
+ }
+
+ if (!response.body) return '';
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > maxBytes) {
+ await reader.cancel();
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider response exceeded the allowed size.'
+ );
+ }
+ chunks.push(value);
+ }
+ return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8');
+};
+
+/** Bounded, no-redirect JSON request for already allowlisted endpoints. */
+export const requestProviderJson = async (
+ endpoint: ValidatedEndpoint,
+ init: Omit,
+ options: ProviderJsonRequestOptions
+): Promise => {
+ if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Provider request timeout is invalid.'
+ );
+ }
+
+ const controller = new AbortController();
+ let timedOut = false;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, options.timeoutMs);
+
+ try {
+ const response = await (options.fetch ?? fetch)(endpoint, {
+ ...init,
+ redirect: 'error',
+ signal: controller.signal
+ });
+
+ if (!response.ok) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider returned an unsuccessful response.',
+ { status: response.status }
+ );
+ }
+
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
+ if (
+ !contentType.includes('application/json') &&
+ !contentType.includes('+json')
+ ) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider returned an unsupported response type.'
+ );
+ }
+
+ const body = await readBoundedBody(
+ response,
+ options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
+ );
+ try {
+ return JSON.parse(body);
+ } catch (cause) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider returned invalid JSON.',
+ { cause }
+ );
+ }
+ } catch (cause) {
+ if (cause instanceof ProviderAdapterError) throw cause;
+ throw new ProviderAdapterError(
+ timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_FAILURE',
+ timedOut
+ ? 'The Provider request timed out.'
+ : 'The Provider request failed.',
+ { cause }
+ );
+ } finally {
+ clearTimeout(timeout);
+ }
+};
diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts
index 42d1c94d82..0d27d33c3c 100644
--- a/packages/oauth/src/index.ts
+++ b/packages/oauth/src/index.ts
@@ -1,30 +1,39 @@
+export type { ProviderAdapter } from './adapter';
export {
- createOAuthMiddleware,
- generateState,
- OAuthCallbackContext,
- OAuthErrorContext,
- OAuthMiddlewareConfig,
- OAuthRouteHandlers,
- verifyState,
-} from './middleware/express';
-export { createOAuthClient,OAuthClient } from './oauth-client';
+ type AuthorizationUrlInput,
+ createAuthorizationUrl,
+ validateProviderCallbackUri} from './authorization';
+export { validateProviderEndpoint } from './endpoint';
export {
- facebookProvider,
- getProvider,
- getProviderIds,
- githubProvider,
- googleProvider,
- linkedinProvider,
- providers,
+ type ProviderJsonRequestOptions,
+ requestProviderJson} from './http';
+export {
+ constantTimeEqual,
+ deriveS256CodeChallenge,
+ generateCodeVerifier,
+ generateOidcNonce,
+ generateOpaqueState,
+ isOpaqueOAuthValue
+} from './primitives';
+export type {
+ ValidatedGitHubConfiguration,
+ ValidatedGoogleConfiguration
+} from './providers';
+export {
+ getProviderAdapter,
+ getProviderAdapterKinds,
+ githubAdapter,
+ googleAdapter
} from './providers';
export {
- AuthorizationUrlParams,
- CallbackParams,
- createOAuthError,
- OAuthClientConfig,
- OAuthCredentials,
- OAuthError,
- OAuthProfile,
- OAuthProviderConfig,
- TokenResponse,
+ type IdentityProviderConfiguration,
+ type NormalizedExternalIdentity,
+ ProviderAdapterError,
+ type ProviderAuthorizationInput,
+ type ProviderAuthorizationResult,
+ type ProviderCallbackInput,
+ type ProviderFailureReason,
+ type SafeExternalProfile,
+ type ValidatedEndpoint,
+ type ValidatedProviderConfiguration
} from './types';
diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts
deleted file mode 100644
index 607d2d76a4..0000000000
--- a/packages/oauth/src/middleware/express.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { OAuthClient } from '../oauth-client';
-import { getProviderIds } from '../providers';
-import { createOAuthError,OAuthClientConfig, OAuthProfile } from '../types';
-import { generateState, verifyState } from '../utils/state';
-
-export interface OAuthMiddlewareConfig extends OAuthClientConfig {
- onSuccess: (profile: OAuthProfile, context: OAuthCallbackContext) => Promise;
- onError?: (error: Error, context: OAuthErrorContext) => void;
- successRedirect?: string;
- errorRedirect?: string;
-}
-
-export interface OAuthCallbackContext {
- provider: string;
- profile: OAuthProfile;
- query: Record;
-}
-
-export interface OAuthErrorContext {
- provider?: string;
- error: Error;
- query: Record;
-}
-
-export interface OAuthRouteHandlers {
- initiateAuth: (
- req: { params: { provider: string }; query: Record },
- res: {
- redirect: (url: string) => void;
- cookie: (name: string, value: string, options: Record) => void;
- status: (code: number) => { json: (data: unknown) => void };
- }
- ) => void;
-
- handleCallback: (
- req: {
- params: { provider: string };
- query: Record;
- cookies: Record;
- },
- res: {
- redirect: (url: string) => void;
- clearCookie: (name: string) => void;
- status: (code: number) => { json: (data: unknown) => void };
- json: (data: unknown) => void;
- }
- ) => Promise;
-
- getProviders: (
- req: unknown,
- res: { json: (data: unknown) => void }
- ) => void;
-}
-
-export function createOAuthMiddleware(config: OAuthMiddlewareConfig): OAuthRouteHandlers {
- const client = new OAuthClient(config);
- const clientConfig = client.getConfig();
-
- const initiateAuth: OAuthRouteHandlers['initiateAuth'] = (req, res) => {
- const { provider } = req.params;
-
- try {
- const { url, state } = client.getAuthorizationUrl({ provider });
-
- res.cookie(clientConfig.stateCookieName!, state, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- maxAge: (clientConfig.stateCookieMaxAge || 600) * 1000,
- sameSite: 'lax',
- });
-
- res.redirect(url);
- } catch (error) {
- if (config.onError) {
- config.onError(error as Error, {
- provider,
- error: error as Error,
- query: req.query as Record,
- });
- }
-
- if (config.errorRedirect) {
- const errorUrl = new URL(config.errorRedirect);
- errorUrl.searchParams.set('error', (error as Error).message);
- errorUrl.searchParams.set('provider', provider);
- res.redirect(errorUrl.toString());
- } else {
- res.status(400).json({
- error: 'oauth_error',
- message: (error as Error).message,
- provider,
- });
- }
- }
- };
-
- const handleCallback: OAuthRouteHandlers['handleCallback'] = async (req, res) => {
- const { provider } = req.params;
- const { code, state, error: oauthError, error_description } = req.query as Record<
- string,
- string
- >;
-
- const storedState = req.cookies[clientConfig.stateCookieName!];
- res.clearCookie(clientConfig.stateCookieName!);
-
- if (oauthError) {
- const error = createOAuthError(
- error_description || oauthError,
- 'OAUTH_PROVIDER_ERROR',
- provider
- );
-
- if (config.onError) {
- config.onError(error, {
- provider,
- error,
- query: req.query as Record,
- });
- }
-
- if (config.errorRedirect) {
- const errorUrl = new URL(config.errorRedirect);
- errorUrl.searchParams.set('error', oauthError);
- if (error_description) {
- errorUrl.searchParams.set('error_description', error_description);
- }
- errorUrl.searchParams.set('provider', provider);
- res.redirect(errorUrl.toString());
- } else {
- res.status(400).json({
- error: 'oauth_error',
- message: error_description || oauthError,
- provider,
- });
- }
- return;
- }
-
- if (!verifyState(storedState, state)) {
- const error = createOAuthError('Invalid state parameter', 'INVALID_STATE', provider);
-
- if (config.onError) {
- config.onError(error, {
- provider,
- error,
- query: req.query as Record,
- });
- }
-
- if (config.errorRedirect) {
- const errorUrl = new URL(config.errorRedirect);
- errorUrl.searchParams.set('error', 'invalid_state');
- errorUrl.searchParams.set('provider', provider);
- res.redirect(errorUrl.toString());
- } else {
- res.status(400).json({
- error: 'invalid_state',
- message: 'Invalid state parameter',
- provider,
- });
- }
- return;
- }
-
- if (!code) {
- const error = createOAuthError('Missing authorization code', 'MISSING_CODE', provider);
-
- if (config.onError) {
- config.onError(error, {
- provider,
- error,
- query: req.query as Record,
- });
- }
-
- if (config.errorRedirect) {
- const errorUrl = new URL(config.errorRedirect);
- errorUrl.searchParams.set('error', 'missing_code');
- errorUrl.searchParams.set('provider', provider);
- res.redirect(errorUrl.toString());
- } else {
- res.status(400).json({
- error: 'missing_code',
- message: 'Missing authorization code',
- provider,
- });
- }
- return;
- }
-
- try {
- const profile = await client.handleCallback({ provider, code });
-
- const result = await config.onSuccess(profile, {
- provider,
- profile,
- query: req.query as Record,
- });
-
- if (config.successRedirect) {
- res.redirect(config.successRedirect);
- } else {
- res.json({ success: true, data: result });
- }
- } catch (error) {
- if (config.onError) {
- config.onError(error as Error, {
- provider,
- error: error as Error,
- query: req.query as Record,
- });
- }
-
- if (config.errorRedirect) {
- const errorUrl = new URL(config.errorRedirect);
- errorUrl.searchParams.set('error', 'callback_failed');
- errorUrl.searchParams.set('message', (error as Error).message);
- errorUrl.searchParams.set('provider', provider);
- res.redirect(errorUrl.toString());
- } else {
- res.status(500).json({
- error: 'callback_failed',
- message: (error as Error).message,
- provider,
- });
- }
- }
- };
-
- const getProviders: OAuthRouteHandlers['getProviders'] = (_req, res) => {
- const configuredProviders = Object.keys(config.providers);
- const availableProviders = getProviderIds().filter((id) => configuredProviders.includes(id));
- res.json({ providers: availableProviders });
- };
-
- return {
- initiateAuth,
- handleCallback,
- getProviders,
- };
-}
-
-export { generateState, verifyState };
diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts
deleted file mode 100644
index cbdd348e50..0000000000
--- a/packages/oauth/src/oauth-client.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { extractPrimaryEmail,getProvider, GITHUB_EMAILS_URL } from './providers';
-import {
- AuthorizationUrlParams,
- CallbackParams,
- createOAuthError,
- OAuthClientConfig,
- OAuthProfile,
- TokenResponse,
-} from './types';
-import { generateState } from './utils/state';
-
-export class OAuthClient {
- private config: OAuthClientConfig;
-
- constructor(config: OAuthClientConfig) {
- this.config = {
- callbackPath: '/auth/{provider}/callback',
- stateCookieName: 'oauth_state',
- stateCookieMaxAge: 600,
- ...config,
- };
- }
-
- getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } {
- const { provider: providerId, state: customState, redirectUri, scopes } = params;
-
- const provider = getProvider(providerId);
- if (!provider) {
- throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId);
- }
-
- const credentials = this.config.providers[providerId];
- if (!credentials) {
- throw createOAuthError(
- `No credentials configured for provider: ${providerId}`,
- 'MISSING_CREDENTIALS',
- providerId
- );
- }
-
- const state = customState || generateState();
- const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri);
- const effectiveScopes = scopes || provider.scopes;
-
- const url = new URL(provider.authorizationUrl);
- url.searchParams.set('client_id', credentials.clientId);
- url.searchParams.set('redirect_uri', callbackUrl);
- url.searchParams.set('response_type', 'code');
- url.searchParams.set('scope', effectiveScopes.join(' '));
- url.searchParams.set('state', state);
-
- return { url: url.toString(), state };
- }
-
- async exchangeCode(params: CallbackParams): Promise {
- const { provider: providerId, code, redirectUri } = params;
-
- const provider = getProvider(providerId);
- if (!provider) {
- throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId);
- }
-
- const credentials = this.config.providers[providerId];
- if (!credentials) {
- throw createOAuthError(
- `No credentials configured for provider: ${providerId}`,
- 'MISSING_CREDENTIALS',
- providerId
- );
- }
-
- const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri);
-
- const body: Record = {
- client_id: credentials.clientId,
- client_secret: credentials.clientSecret,
- code,
- redirect_uri: callbackUrl,
- grant_type: 'authorization_code',
- };
-
- const headers: Record = {
- Accept: 'application/json',
- };
-
- let requestBody: string;
- if (provider.tokenRequestContentType === 'json') {
- headers['Content-Type'] = 'application/json';
- requestBody = JSON.stringify(body);
- } else {
- headers['Content-Type'] = 'application/x-www-form-urlencoded';
- requestBody = new URLSearchParams(body).toString();
- }
-
- const response = await fetch(provider.tokenUrl, {
- method: 'POST',
- headers,
- body: requestBody,
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw createOAuthError(
- `Token exchange failed: ${errorText}`,
- 'TOKEN_EXCHANGE_FAILED',
- providerId,
- response.status
- );
- }
-
- const data = await response.json();
-
- if (data.error) {
- throw createOAuthError(
- `Token exchange error: ${data.error_description || data.error}`,
- 'TOKEN_EXCHANGE_ERROR',
- providerId
- );
- }
-
- return data as TokenResponse;
- }
-
- async getUserProfile(providerId: string, accessToken: string): Promise {
- const provider = getProvider(providerId);
- if (!provider) {
- throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId);
- }
-
- const headers: Record = {
- Authorization: `Bearer ${accessToken}`,
- Accept: 'application/json',
- };
-
- if (providerId === 'github') {
- headers['User-Agent'] = 'Constructive-OAuth';
- }
-
- const response = await fetch(provider.userInfoUrl, {
- method: provider.userInfoMethod || 'GET',
- headers,
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw createOAuthError(
- `Failed to fetch user profile: ${errorText}`,
- 'USER_PROFILE_FAILED',
- providerId,
- response.status
- );
- }
-
- const data = await response.json();
- let profile = provider.mapProfile(data);
-
- if (providerId === 'github' && !profile.email) {
- profile = await this.fetchGitHubEmail(accessToken, profile);
- }
-
- return profile;
- }
-
- async handleCallback(params: CallbackParams): Promise {
- const tokens = await this.exchangeCode(params);
- return this.getUserProfile(params.provider, tokens.access_token);
- }
-
- private async fetchGitHubEmail(
- accessToken: string,
- profile: OAuthProfile
- ): Promise {
- try {
- const response = await fetch(GITHUB_EMAILS_URL, {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: 'application/json',
- 'User-Agent': 'Constructive-OAuth',
- },
- });
-
- if (response.ok) {
- const emails = await response.json();
- const email = extractPrimaryEmail(emails);
- if (email) {
- return { ...profile, email };
- }
- }
- } catch {
- // Ignore email fetch errors, return profile without email
- }
- return profile;
- }
-
- private getCallbackUrl(providerId: string, customRedirectUri?: string): string {
- if (customRedirectUri) {
- return customRedirectUri;
- }
- const path = this.config.callbackPath!.replace('{provider}', providerId);
- return `${this.config.baseUrl}${path}`;
- }
-
- getConfig(): OAuthClientConfig {
- return this.config;
- }
-}
-
-export function createOAuthClient(config: OAuthClientConfig): OAuthClient {
- return new OAuthClient(config);
-}
diff --git a/packages/oauth/src/primitives.ts b/packages/oauth/src/primitives.ts
new file mode 100644
index 0000000000..c510da7e00
--- /dev/null
+++ b/packages/oauth/src/primitives.ts
@@ -0,0 +1,35 @@
+import { createHash, randomBytes, timingSafeEqual } from 'crypto';
+
+import { ProviderAdapterError } from './types';
+
+const BASE64URL_VALUE = /^[A-Za-z0-9_-]+$/;
+const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
+
+export const generateOpaqueState = (): string => randomBytes(32).toString('base64url');
+
+export const generateCodeVerifier = (): string => randomBytes(32).toString('base64url');
+
+export const generateOidcNonce = (): string => randomBytes(32).toString('base64url');
+
+export const deriveS256CodeChallenge = (verifier: string): string => {
+ if (!PKCE_VERIFIER.test(verifier)) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'The PKCE verifier does not satisfy RFC 7636.'
+ );
+ }
+ return createHash('sha256').update(verifier, 'ascii').digest('base64url');
+};
+
+export const isOpaqueOAuthValue = (value: string, byteLength = 32): boolean =>
+ value.length === Math.ceil((byteLength * 4) / 3) &&
+ BASE64URL_VALUE.test(value);
+
+export const constantTimeEqual = (expected: string, actual: string): boolean => {
+ const expectedBuffer = Buffer.from(expected);
+ const actualBuffer = Buffer.from(actual);
+ return (
+ expectedBuffer.length === actualBuffer.length &&
+ timingSafeEqual(expectedBuffer, actualBuffer)
+ );
+};
diff --git a/packages/oauth/src/providers/common.ts b/packages/oauth/src/providers/common.ts
new file mode 100644
index 0000000000..387483e8f8
--- /dev/null
+++ b/packages/oauth/src/providers/common.ts
@@ -0,0 +1,105 @@
+import {
+ type IdentityProviderConfiguration,
+ ProviderAdapterError,
+ type SafeExternalProfile,
+ type ValidatedEndpoint,
+ type ValidatedProviderConfiguration
+} from '../types';
+
+export const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+export const optionalString = (
+ input: Record,
+ key: string
+): string | undefined => {
+ const value = input[key];
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+};
+
+export const requiredString = (
+ input: Record,
+ key: string
+): string => {
+ const value = optionalString(input, key);
+ if (!value) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'The Provider response is missing a required value.'
+ );
+ }
+ return value;
+};
+
+export const configurationValue = (
+ config: IdentityProviderConfiguration,
+ direct: string | null,
+ discoveryKey: string
+): string | null => {
+ if (direct) return direct;
+ const discovered = config.discoveryDoc?.[discoveryKey];
+ return typeof discovered === 'string' ? discovered : null;
+};
+
+export const validateCommonConfiguration = (
+ input: IdentityProviderConfiguration,
+ adapterKind: string,
+ authorizationEndpoint: ValidatedEndpoint,
+ tokenEndpoint: ValidatedEndpoint
+): ValidatedProviderConfiguration => {
+ if (!input.enabled || !input.clientId || !input.clientSecret) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The selected Provider is not enabled or is missing credentials.'
+ );
+ }
+ if (!input.pkceEnabled) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'S256 PKCE is required for every Provider.'
+ );
+ }
+ if (!input.scopes.length) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The selected Provider has no configured scopes.'
+ );
+ }
+ return {
+ adapterKind,
+ providerKey: input.slug,
+ displayName: input.displayName,
+ clientId: input.clientId,
+ clientSecret: input.clientSecret,
+ authorizationEndpoint,
+ tokenEndpoint,
+ scopes: [...input.scopes],
+ extraAuthorizationParams: { ...input.extraAuthorizationParams }
+ };
+};
+
+export const safeProfileValue = (
+ value: unknown,
+ maxLength = 512
+): string | undefined =>
+ typeof value === 'string' && value.length > 0 && value.length <= maxLength
+ ? value
+ : undefined;
+
+export const safeAvatarUrl = (value: unknown): string | undefined => {
+ const candidate = safeProfileValue(value, 2048);
+ if (!candidate) return undefined;
+ try {
+ const url = new URL(candidate);
+ return url.protocol === 'https:' && !url.username && !url.password
+ ? url.toString()
+ : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+export const compactProfile = (profile: SafeExternalProfile): SafeExternalProfile =>
+ Object.fromEntries(
+ Object.entries(profile).filter(([, value]) => value !== undefined)
+ ) as SafeExternalProfile;
diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts
deleted file mode 100644
index 41ed451bff..0000000000
--- a/packages/oauth/src/providers/facebook.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { OAuthProfile,OAuthProviderConfig } from '../types';
-
-interface FacebookProfile {
- id: string;
- name?: string;
- email?: string;
- picture?: {
- data?: {
- url?: string;
- };
- };
-}
-
-const FACEBOOK_API_VERSION = 'v18.0';
-
-export const facebookProvider: OAuthProviderConfig = {
- id: 'facebook',
- name: 'Facebook',
- authorizationUrl: `https://www.facebook.com/${FACEBOOK_API_VERSION}/dialog/oauth`,
- tokenUrl: `https://graph.facebook.com/${FACEBOOK_API_VERSION}/oauth/access_token`,
- userInfoUrl: `https://graph.facebook.com/me?fields=id,name,email,picture`,
- scopes: ['email', 'public_profile'],
- tokenRequestContentType: 'form',
- mapProfile: (data: unknown): OAuthProfile => {
- const profile = data as FacebookProfile;
- return {
- provider: 'facebook',
- providerId: profile.id,
- email: profile.email || null,
- name: profile.name || null,
- picture: profile.picture?.data?.url || null,
- raw: data,
- };
- },
-};
diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts
index 72ec51c4aa..f59047ce0c 100644
--- a/packages/oauth/src/providers/github.ts
+++ b/packages/oauth/src/providers/github.ts
@@ -1,46 +1,207 @@
-import { OAuthProfile,OAuthProviderConfig } from '../types';
-
-interface GitHubProfile {
- id: number;
- login: string;
- name?: string;
- email?: string;
- avatar_url?: string;
-}
+import type { ProviderAdapter } from '../adapter';
+import {
+ createAuthorizationUrl,
+ validateProviderCallbackUri
+} from '../authorization';
+import { validateProviderEndpoint } from '../endpoint';
+import { requestProviderJson } from '../http';
+import { deriveS256CodeChallenge } from '../primitives';
+import {
+ type IdentityProviderConfiguration,
+ type NormalizedExternalIdentity,
+ ProviderAdapterError,
+ type ValidatedEndpoint,
+ type ValidatedProviderConfiguration
+} from '../types';
+import {
+ compactProfile,
+ configurationValue,
+ isRecord,
+ requiredString,
+ safeAvatarUrl,
+ safeProfileValue,
+ validateCommonConfiguration
+} from './common';
+
+const GITHUB_AUTHORIZATION_ENDPOINTS = [
+ 'https://github.com/login/oauth/authorize'
+] as const;
+const GITHUB_TOKEN_ENDPOINTS = [
+ 'https://github.com/login/oauth/access_token'
+] as const;
+const GITHUB_USER_ENDPOINTS = ['https://api.github.com/user'] as const;
+const GITHUB_EMAIL_ENDPOINTS = ['https://api.github.com/user/emails'] as const;
-interface GitHubEmail {
- email: string;
- primary: boolean;
- verified: boolean;
+export interface ValidatedGitHubConfiguration
+ extends ValidatedProviderConfiguration {
+ userEndpoint: ValidatedEndpoint;
+ emailEndpoint: ValidatedEndpoint;
}
-export const githubProvider: OAuthProviderConfig = {
- id: 'github',
- name: 'GitHub',
- authorizationUrl: 'https://github.com/login/oauth/authorize',
- tokenUrl: 'https://github.com/login/oauth/access_token',
- userInfoUrl: 'https://api.github.com/user',
- scopes: ['user:email', 'read:user'],
- tokenRequestContentType: 'json',
- mapProfile: (data: unknown): OAuthProfile => {
- const profile = data as GitHubProfile;
- return {
- provider: 'github',
- providerId: String(profile.id),
- email: profile.email || null,
- name: profile.name || profile.login || null,
- picture: profile.avatar_url || null,
- raw: data,
- };
- },
+const validateGitHubConfiguration = (
+ input: IdentityProviderConfiguration
+): ValidatedGitHubConfiguration => {
+ const authorizationEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.authorizationUrl, 'authorization_endpoint'),
+ GITHUB_AUTHORIZATION_ENDPOINTS
+ );
+ const tokenEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.tokenUrl, 'token_endpoint'),
+ GITHUB_TOKEN_ENDPOINTS
+ );
+ const userEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.userinfoUrl, 'userinfo_endpoint'),
+ GITHUB_USER_ENDPOINTS
+ );
+ const configuredEmailEndpoint = configurationValue(
+ input,
+ null,
+ 'emails_endpoint'
+ );
+ const emailEndpoint = validateProviderEndpoint(
+ configuredEmailEndpoint ?? `${userEndpoint}/emails`,
+ GITHUB_EMAIL_ENDPOINTS
+ );
+
+ return {
+ ...validateCommonConfiguration(
+ input,
+ 'github',
+ authorizationEndpoint,
+ tokenEndpoint
+ ),
+ userEndpoint,
+ emailEndpoint
+ };
};
-export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails';
+const githubHeaders = (accessToken?: string): Record => ({
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'Constructive-OAuth',
+ ...(accessToken && { Authorization: `Bearer ${accessToken}` })
+});
-export function extractPrimaryEmail(emails: GitHubEmail[]): string | null {
- const primary = emails.find((e) => e.primary && e.verified);
- if (primary) return primary.email;
- const verified = emails.find((e) => e.verified);
- if (verified) return verified.email;
- return emails[0]?.email || null;
-}
+const findEmail = (
+ response: unknown
+): { email?: string; verified?: boolean } => {
+ if (!Array.isArray(response)) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'GitHub returned an invalid email response.'
+ );
+ }
+ const emails = response.filter(isRecord);
+ const selected =
+ emails.find(value => value.primary === true && value.verified === true) ??
+ emails.find(value => value.verified === true) ??
+ emails.find(value => typeof value.email === 'string');
+ return selected
+ ? {
+ email: safeProfileValue(selected.email),
+ verified:
+ typeof selected.verified === 'boolean' ? selected.verified : undefined
+ }
+ : {};
+};
+
+export const githubAdapter: ProviderAdapter = {
+ kind: 'github',
+
+ validateConfiguration: validateGitHubConfiguration,
+
+ createAuthorizationRequest: input => ({
+ url: createAuthorizationUrl({
+ endpoint: input.config.authorizationEndpoint,
+ clientId: input.config.clientId,
+ redirectUri: input.redirectUri,
+ scopes: input.config.scopes,
+ state: input.state,
+ codeChallenge: input.codeChallenge,
+ extraParameters: input.config.extraAuthorizationParams
+ })
+ }),
+
+ completeAuthorization: async input => {
+ deriveS256CodeChallenge(input.codeVerifier);
+ validateProviderCallbackUri(input.redirectUri);
+ if (!input.code) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'GitHub callback verification requires an authorization code.'
+ );
+ }
+ const tokenResponse = await requestProviderJson(
+ input.config.tokenEndpoint,
+ {
+ method: 'POST',
+ headers: {
+ ...githubHeaders(),
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: new URLSearchParams({
+ client_id: input.config.clientId,
+ client_secret: input.config.clientSecret,
+ code: input.code,
+ code_verifier: input.codeVerifier,
+ redirect_uri: input.redirectUri
+ }).toString()
+ },
+ { timeoutMs: input.requestTimeoutMs, fetch: input.fetch }
+ );
+ if (!isRecord(tokenResponse)) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'GitHub returned an invalid token response.'
+ );
+ }
+ const accessToken = requiredString(tokenResponse, 'access_token');
+
+ const user = await requestProviderJson(
+ input.config.userEndpoint,
+ { headers: githubHeaders(accessToken) },
+ { timeoutMs: input.requestTimeoutMs, fetch: input.fetch }
+ );
+ if (!isRecord(user)) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'GitHub returned an invalid profile response.'
+ );
+ }
+ const id = user.id;
+ if (
+ (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) &&
+ (typeof id !== 'string' || !id)
+ ) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'GitHub returned an invalid stable identifier.'
+ );
+ }
+
+ let email = safeProfileValue(user.email);
+ let emailVerified: boolean | undefined;
+ if (!email) {
+ const emailResult = findEmail(
+ await requestProviderJson(
+ input.config.emailEndpoint,
+ { headers: githubHeaders(accessToken) },
+ { timeoutMs: input.requestTimeoutMs, fetch: input.fetch }
+ )
+ );
+ email = emailResult.email;
+ emailVerified = emailResult.verified;
+ }
+
+ return {
+ providerKey: input.config.providerKey,
+ subject: String(id),
+ email,
+ profile: compactProfile({
+ name: safeProfileValue(user.name),
+ username: safeProfileValue(user.login),
+ avatarUrl: safeAvatarUrl(user.avatar_url),
+ emailVerified
+ })
+ } satisfies NormalizedExternalIdentity;
+ }
+};
diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts
index dd1c399bb8..8e87f5db0a 100644
--- a/packages/oauth/src/providers/google.ts
+++ b/packages/oauth/src/providers/google.ts
@@ -1,32 +1,235 @@
-import { OAuthProfile,OAuthProviderConfig } from '../types';
-
-interface GoogleProfile {
- sub: string;
- email?: string;
- email_verified?: boolean;
- name?: string;
- given_name?: string;
- family_name?: string;
- picture?: string;
+import {
+ createLocalJWKSet,
+ type JSONWebKeySet,
+ jwtVerify
+} from 'jose';
+
+import type { ProviderAdapter } from '../adapter';
+import {
+ createAuthorizationUrl,
+ validateProviderCallbackUri
+} from '../authorization';
+import { validateProviderEndpoint } from '../endpoint';
+import { requestProviderJson } from '../http';
+import { deriveS256CodeChallenge } from '../primitives';
+import {
+ type IdentityProviderConfiguration,
+ type NormalizedExternalIdentity,
+ ProviderAdapterError,
+ type ValidatedEndpoint,
+ type ValidatedProviderConfiguration
+} from '../types';
+import {
+ compactProfile,
+ configurationValue,
+ isRecord,
+ optionalString,
+ safeAvatarUrl,
+ safeProfileValue,
+ validateCommonConfiguration
+} from './common';
+
+const GOOGLE_AUTHORIZATION_ENDPOINTS = [
+ 'https://accounts.google.com/o/oauth2/v2/auth'
+] as const;
+const GOOGLE_TOKEN_ENDPOINTS = ['https://oauth2.googleapis.com/token'] as const;
+const GOOGLE_ISSUERS = ['https://accounts.google.com'] as const;
+const GOOGLE_JWKS_ENDPOINTS = [
+ 'https://www.googleapis.com/oauth2/v3/certs',
+ 'https://www.googleapis.com/oauth2/v1/certs'
+] as const;
+
+export interface ValidatedGoogleConfiguration
+ extends ValidatedProviderConfiguration {
+ issuer: string;
+ acceptableAudiences: readonly string[];
+ jwks?: JSONWebKeySet;
+ jwksEndpoint?: ValidatedEndpoint;
}
-export const googleProvider: OAuthProviderConfig = {
- id: 'google',
- name: 'Google',
- 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'],
- tokenRequestContentType: 'form',
- mapProfile: (data: unknown): OAuthProfile => {
- const profile = data as GoogleProfile;
+const validateJwks = (value: Record | null): JSONWebKeySet | undefined => {
+ if (!value) return undefined;
+ if (!Array.isArray(value.keys)) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Google Provider JWKS configuration is invalid.'
+ );
+ }
+ return value as unknown as JSONWebKeySet;
+};
+
+const validateGoogleConfiguration = (
+ input: IdentityProviderConfiguration
+): ValidatedGoogleConfiguration => {
+ if (input.skipNonceCheck) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'OIDC nonce verification is required for Google.'
+ );
+ }
+ if (!input.scopes.includes('openid')) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Google Provider must include the openid scope.'
+ );
+ }
+
+ const authorizationEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.authorizationUrl, 'authorization_endpoint'),
+ GOOGLE_AUTHORIZATION_ENDPOINTS
+ );
+ const tokenEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.tokenUrl, 'token_endpoint'),
+ GOOGLE_TOKEN_ENDPOINTS
+ );
+ const issuerEndpoint = validateProviderEndpoint(
+ configurationValue(input, input.issuerUrl, 'issuer'),
+ GOOGLE_ISSUERS
+ );
+ const jwks = validateJwks(input.jwks);
+ const jwksValue = configurationValue(input, null, 'jwks_uri');
+ const jwksEndpoint = jwksValue
+ ? validateProviderEndpoint(jwksValue, GOOGLE_JWKS_ENDPOINTS)
+ : undefined;
+ if (!jwks && !jwksEndpoint) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The Google Provider has no configured JWKS source.'
+ );
+ }
+
+ return {
+ ...validateCommonConfiguration(
+ input,
+ 'google',
+ authorizationEndpoint,
+ tokenEndpoint
+ ),
+ issuer: issuerEndpoint.replace(/\/$/, ''),
+ acceptableAudiences: [input.clientId, ...input.acceptableClientIds],
+ jwks,
+ jwksEndpoint
+ };
+};
+
+export const googleAdapter: ProviderAdapter = {
+ kind: 'google',
+
+ validateConfiguration: validateGoogleConfiguration,
+
+ createAuthorizationRequest: input => {
+ if (!input.nonce) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'Google authorization requires an OIDC nonce.'
+ );
+ }
return {
- provider: 'google',
- providerId: profile.sub,
- email: profile.email || null,
- name: profile.name || null,
- picture: profile.picture || null,
- raw: data,
+ url: createAuthorizationUrl({
+ endpoint: input.config.authorizationEndpoint,
+ clientId: input.config.clientId,
+ redirectUri: input.redirectUri,
+ scopes: input.config.scopes,
+ state: input.state,
+ codeChallenge: input.codeChallenge,
+ nonce: input.nonce,
+ extraParameters: input.config.extraAuthorizationParams
+ })
};
},
+
+ completeAuthorization: async input => {
+ deriveS256CodeChallenge(input.codeVerifier);
+ validateProviderCallbackUri(input.redirectUri);
+ if (!input.code || !input.nonce) {
+ throw new ProviderAdapterError(
+ 'INVALID_AUTHORIZATION_INPUT',
+ 'Google callback verification requires a code and the original nonce.'
+ );
+ }
+
+ const tokenResponse = await requestProviderJson(
+ input.config.tokenEndpoint,
+ {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: new URLSearchParams({
+ client_id: input.config.clientId,
+ client_secret: input.config.clientSecret,
+ code: input.code,
+ code_verifier: input.codeVerifier,
+ grant_type: 'authorization_code',
+ redirect_uri: input.redirectUri
+ }).toString()
+ },
+ { timeoutMs: input.requestTimeoutMs, fetch: input.fetch }
+ );
+ if (!isRecord(tokenResponse)) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'Google returned an invalid token response.'
+ );
+ }
+ const identityToken = optionalString(tokenResponse, 'id_token');
+ if (!identityToken) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'Google did not return an identity token.'
+ );
+ }
+
+ let jwks = input.config.jwks;
+ if (!jwks && input.config.jwksEndpoint) {
+ const remote = await requestProviderJson(
+ input.config.jwksEndpoint,
+ { headers: { Accept: 'application/json' } },
+ { timeoutMs: input.requestTimeoutMs, fetch: input.fetch }
+ );
+ if (!isRecord(remote) || !Array.isArray(remote.keys)) {
+ throw new ProviderAdapterError(
+ 'INVALID_RESPONSE',
+ 'Google returned an invalid JWKS response.'
+ );
+ }
+ jwks = remote as unknown as JSONWebKeySet;
+ }
+
+ try {
+ const { payload } = await jwtVerify(identityToken, createLocalJWKSet(jwks!), {
+ algorithms: ['RS256'],
+ audience: [...input.config.acceptableAudiences],
+ issuer: input.config.issuer
+ });
+ if (payload.nonce !== input.nonce || typeof payload.sub !== 'string') {
+ throw new ProviderAdapterError(
+ 'IDENTITY_VERIFICATION_FAILED',
+ 'Google identity verification failed.'
+ );
+ }
+
+ return {
+ providerKey: input.config.providerKey,
+ subject: payload.sub,
+ email: safeProfileValue(payload.email),
+ profile: compactProfile({
+ name: safeProfileValue(payload.name),
+ avatarUrl: safeAvatarUrl(payload.picture),
+ emailVerified:
+ typeof payload.email_verified === 'boolean'
+ ? payload.email_verified
+ : undefined
+ })
+ } satisfies NormalizedExternalIdentity;
+ } catch (cause) {
+ if (cause instanceof ProviderAdapterError) throw cause;
+ throw new ProviderAdapterError(
+ 'IDENTITY_VERIFICATION_FAILED',
+ 'Google identity verification failed.',
+ { cause }
+ );
+ }
+ }
};
diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts
index 23927d410c..2415066ff9 100644
--- a/packages/oauth/src/providers/index.ts
+++ b/packages/oauth/src/providers/index.ts
@@ -1,29 +1,28 @@
-import { OAuthProviderConfig } from '../types';
-import { facebookProvider } from './facebook';
-import { extractPrimaryEmail,GITHUB_EMAILS_URL, githubProvider } from './github';
-import { googleProvider } from './google';
-import { linkedinProvider } from './linkedin';
+import type { ProviderAdapter } from '../adapter';
+import { ProviderAdapterError } from '../types';
+import { githubAdapter } from './github';
+import { googleAdapter } from './google';
-export const providers: Record = {
- google: googleProvider,
- github: githubProvider,
- facebook: facebookProvider,
- linkedin: linkedinProvider,
-};
+const providerAdapters = new Map([
+ [googleAdapter.kind, googleAdapter as ProviderAdapter],
+ [githubAdapter.kind, githubAdapter as ProviderAdapter]
+]);
-export function getProvider(id: string): OAuthProviderConfig | undefined {
- return providers[id];
-}
+export const getProviderAdapter = (providerKey: string): ProviderAdapter => {
+ const adapter = providerAdapters.get(providerKey);
+ if (!adapter) {
+ throw new ProviderAdapterError(
+ 'INVALID_CONFIGURATION',
+ 'The selected identity Provider is not supported.'
+ );
+ }
+ return adapter;
+};
-export function getProviderIds(): string[] {
- return Object.keys(providers);
-}
+export const getProviderAdapterKinds = (): readonly string[] =>
+ [...providerAdapters.keys()];
-export {
- extractPrimaryEmail,
- facebookProvider,
- GITHUB_EMAILS_URL,
- githubProvider,
- googleProvider,
- linkedinProvider,
-};
+export type { ValidatedGitHubConfiguration } from './github';
+export { githubAdapter } from './github';
+export type { ValidatedGoogleConfiguration } from './google';
+export { googleAdapter } from './google';
diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts
deleted file mode 100644
index 9050c9be6a..0000000000
--- a/packages/oauth/src/providers/linkedin.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { OAuthProfile,OAuthProviderConfig } from '../types';
-
-interface LinkedInProfile {
- sub: string;
- email?: string;
- email_verified?: boolean;
- name?: string;
- given_name?: string;
- family_name?: string;
- picture?: string;
-}
-
-export const linkedinProvider: OAuthProviderConfig = {
- id: 'linkedin',
- name: 'LinkedIn',
- authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization',
- tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
- userInfoUrl: 'https://api.linkedin.com/v2/userinfo',
- scopes: ['openid', 'profile', 'email'],
- tokenRequestContentType: 'form',
- mapProfile: (data: unknown): OAuthProfile => {
- const profile = data as LinkedInProfile;
- return {
- provider: 'linkedin',
- providerId: profile.sub,
- email: profile.email || null,
- name: profile.name || null,
- picture: profile.picture || null,
- raw: data,
- };
- },
-};
diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts
index db4ef07437..2c99238a54 100644
--- a/packages/oauth/src/types.ts
+++ b/packages/oauth/src/types.ts
@@ -1,74 +1,108 @@
-export interface OAuthProviderConfig {
- id: string;
- name: string;
- authorizationUrl: string;
- tokenUrl: string;
- userInfoUrl: string;
+export interface IdentityProviderConfiguration {
+ slug: string;
+ kind: string;
+ displayName: string;
+ enabled: boolean;
+ clientId: string;
+ clientSecret: string | null;
+ authorizationUrl: string | null;
+ tokenUrl: string | null;
+ userinfoUrl: string | null;
+ issuerUrl: string | null;
+ discoveryDoc: Record | null;
+ jwks: Record | null;
+ acceptableClientIds: string[];
scopes: string[];
- tokenRequestContentType?: 'json' | 'form';
- userInfoMethod?: 'GET' | 'POST';
- mapProfile: (data: unknown) => OAuthProfile;
+ extraAuthorizationParams: Record;
+ emailOptional: boolean;
+ skipNonceCheck: boolean;
+ pkceEnabled: boolean;
}
-export interface OAuthProfile {
- provider: string;
- providerId: string;
- email: string | null;
- name: string | null;
- picture: string | null;
- raw: unknown;
-}
+declare const validatedEndpoint: unique symbol;
-export interface OAuthCredentials {
+/** An HTTPS endpoint that has passed a concrete adapter's exact allowlist. */
+export type ValidatedEndpoint = string & {
+ readonly [validatedEndpoint]: true;
+};
+
+export interface ValidatedProviderConfiguration {
+ adapterKind: string;
+ providerKey: string;
+ displayName: string;
clientId: string;
clientSecret: string;
- redirectUri?: string;
+ authorizationEndpoint: ValidatedEndpoint;
+ tokenEndpoint: ValidatedEndpoint;
+ scopes: readonly string[];
+ extraAuthorizationParams: Readonly>;
}
-export interface OAuthClientConfig {
- providers: Record;
- baseUrl: string;
- callbackPath?: string;
- stateCookieName?: string;
- stateCookieMaxAge?: number;
+export interface ProviderAuthorizationInput<
+ C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration
+> {
+ config: C;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+ nonce?: string;
}
-export interface TokenResponse {
- access_token: string;
- token_type: string;
- expires_in?: number;
- refresh_token?: string;
- scope?: string;
+export interface ProviderAuthorizationResult {
+ url: string;
}
-export interface AuthorizationUrlParams {
- provider: string;
- state?: string;
- redirectUri?: string;
- scopes?: string[];
+export interface ProviderCallbackInput<
+ C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration
+> {
+ config: C;
+ redirectUri: string;
+ code: string;
+ codeVerifier: string;
+ nonce?: string;
+ requestTimeoutMs: number;
+ fetch?: typeof fetch;
}
-export interface CallbackParams {
- provider: string;
- code: string;
- redirectUri?: string;
+export interface SafeExternalProfile {
+ name?: string;
+ username?: string;
+ avatarUrl?: string;
+ emailVerified?: boolean;
}
-export interface OAuthError extends Error {
- code: string;
- provider?: string;
- statusCode?: number;
+/** The only Provider result consumed by common Constructive orchestration. */
+export interface NormalizedExternalIdentity {
+ providerKey: string;
+ subject: string;
+ email?: string;
+ profile: SafeExternalProfile;
}
-export function createOAuthError(
- message: string,
- code: string,
- provider?: string,
- statusCode?: number
-): OAuthError {
- const error = new Error(message) as OAuthError;
- error.code = code;
- error.provider = provider;
- error.statusCode = statusCode;
- return error;
+export type ProviderFailureReason =
+ | 'INVALID_CONFIGURATION'
+ | 'INVALID_AUTHORIZATION_INPUT'
+ | 'NETWORK_FAILURE'
+ | 'REQUEST_TIMEOUT'
+ | 'INVALID_RESPONSE'
+ | 'IDENTITY_VERIFICATION_FAILED';
+
+/**
+ * Package-local failure classification. Transport owners map it to canonical
+ * Constructive errors and never expose Provider response bodies.
+ */
+export class ProviderAdapterError extends Error {
+ readonly reason: ProviderFailureReason;
+ readonly status?: number;
+
+ constructor(
+ reason: ProviderFailureReason,
+ message: string,
+ options?: ErrorOptions & { status?: number }
+ ) {
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
+ this.name = 'ProviderAdapterError';
+ this.reason = reason;
+ this.status = options?.status;
+ }
}
diff --git a/packages/oauth/src/utils/state.ts b/packages/oauth/src/utils/state.ts
deleted file mode 100644
index c7a60f2818..0000000000
--- a/packages/oauth/src/utils/state.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { generateToken as generateState, verifyToken as verifyState } from '@constructive-io/csrf';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 257ab13c58..b3a8802ef0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2586,9 +2586,9 @@ importers:
packages/oauth:
dependencies:
- '@constructive-io/csrf':
- specifier: workspace:^
- version: link:../csrf/dist
+ jose:
+ specifier: ^5.10.0
+ version: 5.10.0
devDependencies:
'@types/node':
specifier: ^22.19.11
@@ -11645,6 +11645,12 @@ packages:
}
hasBin: true
+ jose@5.10.0:
+ resolution:
+ {
+ integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==,
+ }
+
js-tokens@4.0.0:
resolution:
{
@@ -21173,6 +21179,8 @@ snapshots:
jiti@2.7.0: {}
+ jose@5.10.0: {}
+
js-tokens@4.0.0: {}
js-yaml@3.14.2: