From 15858d9faf3c048125ad85d3d56bdd267fc74862 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 17:49:25 -0400 Subject: [PATCH] feat: oauth addition / support --- README.md | 101 ++++++++++++++++++++++++- src/AuthProvider.tsx | 25 ++++++ src/client/createSeamlessAuthClient.ts | 73 +++++++++++++++++- src/index.ts | 10 +++ tests/createSeamlessAuthClient.test.ts | 53 +++++++++++++ 5 files changed, 260 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6ed4a8b..fad3cf0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `createSeamlessAuthClient()` - `useAuthClient()` - `usePasskeySupport()` -- types including `AuthMode`, `AuthContextType`, `Credential`, `User`, `StepUpStatus`, and the headless client input/result types +- types including `AuthMode`, `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, and the headless client input/result types ## Installation @@ -104,6 +104,9 @@ You are still responsible for your app’s route protection and redirects. hasSignedInBefore: boolean; markSignedIn(): void; hasRole(role: string): boolean | undefined; + listOAuthProviders(): Promise; + startOAuthLogin(input: StartOAuthLoginInput): Promise; + finishOAuthLogin(input: FinishOAuthLoginInput): Promise; refreshSession(): Promise; refreshStepUpStatus(): Promise; verifyStepUpWithPasskey(): Promise; @@ -235,6 +238,95 @@ const vaultUnlockMaterial: { credentialId: string; output: Uint8Array } = { The salt may be an `ArrayBuffer`, `ArrayBufferView`, or base64url string. Authentication proves identity and user presence; the PRF output is local key material for your application to use without sending it to Seamless Auth. +### OAuth Login + +OAuth lets your app offer external identity providers such as Google, GitHub, Facebook, or custom +OIDC-style providers configured on the Seamless Auth API. The React SDK does not receive provider +access tokens. It only starts the provider redirect and completes the callback so Seamless Auth can +issue the normal access/refresh session. + +Use `listOAuthProviders()` when you want to render enabled providers dynamically: + +```tsx +import { useEffect, useState } from 'react'; +import { useAuth } from '@seamless-auth/react'; +import type { OAuthProvider } from '@seamless-auth/react'; + +function OAuthButtons() { + const { listOAuthProviders, startOAuthLogin } = useAuth(); + const [providers, setProviders] = useState([]); + + useEffect(() => { + void listOAuthProviders().then((result) => setProviders(result.providers)); + }, [listOAuthProviders]); + + async function signIn(providerId: string) { + const result = await startOAuthLogin({ + providerId, + redirectUri: `${window.location.origin}/oauth/callback`, + returnTo: `${window.location.origin}/dashboard`, + }); + + window.location.assign(result.authorizationUrl); + } + + return ( +
+ {providers.map((provider) => ( + + ))} +
+ ); +} +``` + +Create a callback route that reads the provider query params and asks Seamless Auth to complete the +login: + +```tsx +import { useEffect } from 'react'; +import { useAuth } from '@seamless-auth/react'; + +function OAuthCallback() { + const { finishOAuthLogin } = useAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const providerId = 'google'; + const code = params.get('code'); + const state = params.get('state'); + + if (!code || !state) { + return; + } + + void finishOAuthLogin({ providerId, code, state }).then(() => { + window.location.assign('/dashboard'); + }); + }, [finishOAuthLogin]); + + return

Finishing sign-in...

; +} +``` + +For fully custom UI without `useAuth()`, call the headless client directly: + +```ts +const providers = await authClient.listOAuthProviders(); +const started = await authClient.startOAuthLogin({ + providerId: providers.providers[0].id, + redirectUri: `${window.location.origin}/oauth/callback`, +}); + +window.location.assign(started.authorizationUrl); +``` + +OAuth must be enabled on the Seamless Auth API with `LOGIN_METHODS` including `oauth` and at least +one configured `oauth_providers` entry. Provider client secrets live on the server and are referenced +by environment variable name; they are never passed through this SDK. + ## Headless Client For custom auth UIs, use the exported client directly: @@ -264,6 +356,7 @@ The headless client exposes helpers for: - registration - phone OTP and email OTP - magic-link request, verify, and polling +- OAuth provider listing, start, and callback completion - passkey registration - step-up status and passkey verification - logout and delete-user @@ -276,6 +369,9 @@ Client methods return raw `Response` objects except for the passkey convenience - `isPasskeyPrfSupported(): Promise` - `verifyStepUpWithPasskey(): Promise` - `verifyStepUpWithPasskeyPrf(input): Promise` +- `listOAuthProviders(): Promise` +- `startOAuthLogin(input): Promise` +- `finishOAuthLogin(input): Promise` ## React Hooks For Custom UI @@ -348,6 +444,9 @@ The built-in flows assume compatible endpoints for: - `/magic-link` - `/magic-link/check` - `/magic-link/verify/:token` +- `/oauth/providers` +- `/oauth/:providerId/start` +- `/oauth/:providerId/callback` - `/step-up/status` - `/step-up/webauthn/start` - `/step-up/webauthn/finish` diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 28deb85..ce02c21 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -6,6 +6,10 @@ import { createSeamlessAuthClient, + FinishOAuthLoginInput, + OAuthProvidersResult, + StartOAuthLoginInput, + StartOAuthLoginResult, StepUpWithPasskeyPrfResult, StepUpStatus, StepUpVerificationResult, @@ -40,6 +44,9 @@ export interface AuthContextType { organizations: Organization[]; activeOrganization: Organization | null; switchOrganization: (organizationId: string) => Promise; + listOAuthProviders: () => Promise; + startOAuthLogin: (input: StartOAuthLoginInput) => Promise; + finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise; stepUpStatus: StepUpStatus | null; updateCredential: (credential: Credential) => Promise; deleteCredential: (credentialId: string) => Promise; @@ -235,6 +242,21 @@ export const AuthProvider: React.FC = ({ await validateToken(); }; + const listOAuthProviders = () => authClient.listOAuthProviders(); + + const startOAuthLogin = (input: StartOAuthLoginInput) => + authClient.startOAuthLogin(input); + + const finishOAuthLogin = async (input: FinishOAuthLoginInput) => { + const response = await authClient.finishOAuthLogin(input); + + if (!response.ok) { + throw new Error('Failed to finish OAuth login'); + } + + await validateToken(); + }; + const refreshStepUpStatus = useCallback(async () => { const response = await authClient.getStepUpStatus(); @@ -311,6 +333,9 @@ export const AuthProvider: React.FC = ({ organizations, activeOrganization, switchOrganization, + listOAuthProviders, + startOAuthLogin, + finishOAuthLogin, stepUpStatus, updateCredential, deleteCredential, diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 61ac43b..6daa346 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -35,7 +35,7 @@ export interface LoginInput { passkeyAvailable: boolean; } -export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp'; +export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth'; export interface LoginStartResult { message?: string; @@ -101,6 +101,34 @@ export interface OrganizationMembersResult { total: number; } +export interface OAuthProvider { + id: string; + name: string; + scopes: string[]; +} + +export interface OAuthProvidersResult { + providers: OAuthProvider[]; +} + +export interface StartOAuthLoginInput { + providerId: string; + redirectUri?: string; + returnTo?: string; +} + +export interface StartOAuthLoginResult { + provider: OAuthProvider; + state: string; + authorizationUrl: string; +} + +export interface FinishOAuthLoginInput { + providerId: string; + code: string; + state: string; +} + export interface PasskeyLoginResult { success: boolean; mfaRequired: boolean; @@ -166,6 +194,9 @@ export interface SeamlessAuthClient { requestMagicLink: () => Promise; checkMagicLink: () => Promise; verifyMagicLink: (token: string) => Promise; + listOAuthProviders: () => Promise; + startOAuthLogin: (input: StartOAuthLoginInput) => Promise; + finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise; registerPasskey: ( input: PasskeyMetadata | RegisterPasskeyOptions ) => Promise; @@ -444,6 +475,46 @@ export const createSeamlessAuthClient = ( }, }), + listOAuthProviders: async () => { + const response = await fetchWithAuth(`/oauth/providers`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('Failed to list OAuth providers.'); + } + + return response.json(); + }, + + startOAuthLogin: async input => { + const response = await fetchWithAuth( + `/oauth/${encodeURIComponent(input.providerId)}/start`, + { + method: 'POST', + body: JSON.stringify({ + ...(input.redirectUri ? { redirectUri: input.redirectUri } : {}), + ...(input.returnTo ? { returnTo: input.returnTo } : {}), + }), + } + ); + + if (!response.ok) { + throw new Error('Failed to start OAuth login.'); + } + + return response.json(); + }, + + finishOAuthLogin: input => + fetchWithAuth(`/oauth/${encodeURIComponent(input.providerId)}/callback`, { + method: 'POST', + body: JSON.stringify({ + code: input.code, + state: input.state, + }), + }), + registerPasskey: async input => { const registerInput = normalizeRegisterPasskeyInput(input); const challengeRes = await fetchWithAuth(buildRegisterStartPath(registerInput), { diff --git a/src/index.ts b/src/index.ts index 14ac92a..23fa5f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,12 @@ import { createSeamlessAuthClient, CreateOrganizationInput, CurrentUserResult, + FinishOAuthLoginInput, LoginInput, LoginMethod, LoginStartResult, + OAuthProvider, + OAuthProvidersResult, OrganizationMemberInput, OrganizationMemberUpdateInput, OrganizationMembersResult, @@ -26,6 +29,8 @@ import { RegisterPasskeyOptions, SeamlessAuthClient, SeamlessAuthClientOptions, + StartOAuthLoginInput, + StartOAuthLoginResult, StepUpMethod, StepUpStatus, StepUpWithPasskeyPrfResult, @@ -61,9 +66,12 @@ export type { Credential, CreateOrganizationInput, CurrentUserResult, + FinishOAuthLoginInput, LoginInput, LoginMethod, LoginStartResult, + OAuthProvider, + OAuthProvidersResult, Organization, OrganizationMemberInput, OrganizationMembership, @@ -81,6 +89,8 @@ export type { RegisterPasskeyOptions, SeamlessAuthClient, SeamlessAuthClientOptions, + StartOAuthLoginInput, + StartOAuthLoginResult, StepUpMethod, StepUpStatus, StepUpWithPasskeyPrfResult, diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 96ffcac..aaa0ba1 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -158,6 +158,59 @@ describe('createSeamlessAuthClient', () => { ); }); + it('uses OAuth login endpoints', async () => { + const providersResult = { + providers: [{ id: 'google', name: 'Google', scopes: ['openid', 'email'] }], + }; + const startResult = { + provider: providersResult.providers[0], + state: 'state', + authorizationUrl: 'https://accounts.example.com/auth', + }; + const finishResponse = { ok: true }; + + mockFetchWithAuth + .mockResolvedValueOnce({ ok: true, json: async () => providersResult }) + .mockResolvedValueOnce({ ok: true, json: async () => startResult }) + .mockResolvedValueOnce(finishResponse); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'server', + }); + + await expect(client.listOAuthProviders()).resolves.toEqual(providersResult); + await expect( + client.startOAuthLogin({ + providerId: 'google', + redirectUri: 'https://app.example.com/oauth/callback', + returnTo: 'https://app.example.com/dashboard', + }) + ).resolves.toEqual(startResult); + await expect( + client.finishOAuthLogin({ + providerId: 'google', + code: 'code', + state: 'state', + }) + ).resolves.toBe(finishResponse); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/oauth/providers', { + method: 'GET', + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/oauth/google/start', { + method: 'POST', + body: JSON.stringify({ + redirectUri: 'https://app.example.com/oauth/callback', + returnTo: 'https://app.example.com/dashboard', + }), + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(3, '/oauth/google/callback', { + method: 'POST', + body: JSON.stringify({ code: 'code', state: 'state' }), + }); + }); + it('returns a successful passkey login result when both WebAuthn steps succeed', async () => { mockFetchWithAuth .mockResolvedValueOnce({