From 393859ea0c4137ea23f9eacc2cf5ad07ba944828 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:21:28 +0200 Subject: [PATCH 1/5] feat: define canonical OAuth adapter contract --- src/auth.ts | 156 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 139 insertions(+), 17 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index b7b0c3d..a2ba6e3 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -130,14 +130,17 @@ export interface AuthAdapterError { cause?: unknown; } -export type AuthResult = +export type AuthResult< + TData = void, + TError extends AuthAdapterError = AuthAdapterError, +> = | { ok: true; data?: TData; } | { ok: false; - error: AuthAdapterError; + error: TError; }; export interface SignInInput { @@ -172,22 +175,141 @@ export interface VerifyOtpInput { metadata?: Record; } -export interface SignInWithOAuthInput { +export const AUTH_OAUTH_ERROR_STAGES = [ + 'start', + 'transport', + 'callback', + 'exchange', + 'session', + 'profile', +] as const; +export type AuthOAuthErrorStage = (typeof AUTH_OAUTH_ERROR_STAGES)[number]; + +export const AUTH_OAUTH_ERROR_CODES = [ + 'oauth_unavailable', + 'provider_disabled', + 'provider_misconfigured', + 'invalid_redirect_uri', + 'authorization_failed', + 'authorization_attempt_not_found', + 'invalid_callback', + 'state_mismatch', + 'pkce_mismatch', + 'callback_already_completed', + 'code_exchange_failed', + 'network_error', + 'session_persistence_failed', + 'profile_creation_failed', + 'provider_error', +] as const; +export type AuthOAuthErrorCode = (typeof AUTH_OAUTH_ERROR_CODES)[number]; + +export interface AuthOAuthError extends AuthAdapterError { + code: AuthOAuthErrorCode; + stage: AuthOAuthErrorStage; + provider?: AuthOAuthProviderId; + recoverable: boolean; +} + +export const AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS = [ + 'user_cancelled', + 'browser_dismissed', +] as const; +export type AuthOAuthTransportCancellationReason = + (typeof AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS)[number]; + +export const AUTH_OAUTH_CANCELLATION_REASONS = [ + ...AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS, + 'provider_denied', +] as const; +export type AuthOAuthCancellationReason = (typeof AUTH_OAUTH_CANCELLATION_REASONS)[number]; + +export const AUTH_OAUTH_TRANSPORT_ERROR_CODES = [ + 'browser_unavailable', + 'transport_failed', +] as const; +export type AuthOAuthTransportErrorCode = (typeof AUTH_OAUTH_TRANSPORT_ERROR_CODES)[number]; + +export interface AuthOAuthTransportError { + code: AuthOAuthTransportErrorCode; + message: string; + cause?: unknown; +} + +export interface StartOAuthAuthorizationInput { provider: AuthOAuthProviderId; - redirectTo?: string; - scopes?: string[]; - queryParams?: Record; - metadata?: Record; + redirectUri: string; + scopes?: readonly string[]; + queryParams?: Readonly>; } -export interface AuthOAuthRedirect { +export interface AuthOAuthAuthorizationRequest { + attemptId: string; provider: AuthOAuthProviderId; - url: string; + authorizationUrl: string; + redirectUri: string; } -export interface CompleteOAuthSignInInput { - url: string; - redirectTo?: string; +export type AuthOAuthStartResult = + | { + ok: true; + data: AuthOAuthAuthorizationRequest; + } + | { + ok: false; + error: AuthOAuthError; + }; + +export type AuthOAuthAuthorizationResponse = + | { + type: 'callback'; + url: string; + } + | { + type: 'cancelled'; + reason: AuthOAuthTransportCancellationReason; + } + | { + type: 'error'; + error: AuthOAuthTransportError; + }; + +export interface CompleteOAuthAuthorizationInput { + attemptId: string; + response: AuthOAuthAuthorizationResponse; +} + +export type AuthOAuthCompletionResult = + | { + ok: true; + status: 'authenticated'; + provider: AuthOAuthProviderId; + session: AuthSession; + } + | { + ok: false; + status: 'cancelled'; + provider: AuthOAuthProviderId; + reason: AuthOAuthCancellationReason; + } + | { + ok: false; + status: 'error'; + error: AuthOAuthError; + }; + +export interface AuthOAuthCapabilities { + /** Enabled providers for which start and callback completion are operational. */ + providers: readonly [AuthOAuthProviderId, ...AuthOAuthProviderId[]]; +} + +export interface AuthOAuthAdapter { + readonly capabilities: AuthOAuthCapabilities; + + startAuthorization(input: StartOAuthAuthorizationInput): Promise; + completeAuthorization( + input: CompleteOAuthAuthorizationInput, + ): Promise; } export interface AuthAdapterCapabilities { @@ -196,12 +318,15 @@ export interface AuthAdapterCapabilities { supportsPasswordReset: boolean; supportsOtp: boolean; supportsSessionRefresh: boolean; - supportsOAuth?: boolean; - oauthProviders?: AuthOAuthProviderId[]; } export interface AuthAdapter { readonly capabilities?: AuthAdapterCapabilities; + /** + * Presence is the canonical OAuth capability signal. When present, both authorization start and + * callback completion are mandatory and operational for every advertised provider. + */ + readonly oauth?: AuthOAuthAdapter; signIn(input: SignInInput): Promise>; signUp(input: SignUpInput): Promise>; @@ -212,7 +337,4 @@ export interface AuthAdapter { requestPasswordReset?(input: PasswordResetInput): Promise; verifyOtp?(input: VerifyOtpInput): Promise>; - - signInWithOAuth?(input: SignInWithOAuthInput): Promise>; - completeOAuthSignIn?(input: CompleteOAuthSignInInput): Promise>; } From 948e8936aefdc46fb68b6493926195b22f71bf89 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:22:02 +0200 Subject: [PATCH 2/5] test: cover canonical OAuth adapter lifecycle --- src/auth-oauth.test.ts | 232 +++++++++++++++++++++++++++++++++-------- 1 file changed, 189 insertions(+), 43 deletions(-) diff --git a/src/auth-oauth.test.ts b/src/auth-oauth.test.ts index 7fc87c2..8e129a1 100644 --- a/src/auth-oauth.test.ts +++ b/src/auth-oauth.test.ts @@ -1,12 +1,58 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'bun:test'; import { + AUTH_OAUTH_CANCELLATION_REASONS, + AUTH_OAUTH_ERROR_CODES, AUTH_OAUTH_PROVIDER_IDS, type AuthAdapter, type AuthOAuthConfig, + type AuthSession, type AuthSpec, } from './index'; +const session: AuthSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { + id: 'user-1', + email: 'person@example.com', + }, +}; + +function createBaseAdapter(): Omit { + return { + capabilities: { + signInIdentifiers: ['email'], + supportsSignUp: true, + supportsPasswordReset: true, + supportsOtp: true, + supportsSessionRefresh: true, + }, + signIn() { + return Promise.resolve({ + ok: false, + error: { code: 'unsupported', message: 'Password sign-in is not implemented.' }, + }); + }, + signUp() { + return Promise.resolve({ + ok: false, + error: { code: 'unsupported', message: 'Sign-up is not implemented.' }, + }); + }, + signOut() { + return Promise.resolve({ ok: true }); + }, + getSession() { + return Promise.resolve({ ok: true, data: null }); + }, + }; +} + describe('OAuth auth contracts', () => { it('accepts provider-neutral OAuth provider config on auth specs', () => { const oauth: AuthOAuthConfig = { @@ -38,7 +84,6 @@ describe('OAuth auth contracts', () => { const auth: AuthSpec = { scope: 'global', provider: 'supabase', - authorization: { kind: 'RBAC', engine: 'native' }, oauth, }; @@ -48,56 +93,157 @@ describe('OAuth auth contracts', () => { expect(auth.oauth?.providers[1]?.enabled).toBe(false); }); - it('accepts optional OAuth adapter capabilities and redirect flow methods', async () => { + it('advertises OAuth only through one complete start and completion capability', async () => { const adapter: AuthAdapter = { - capabilities: { - signInIdentifiers: ['email'], - supportsSignUp: true, - supportsPasswordReset: true, - supportsOtp: true, - supportsSessionRefresh: true, - supportsOAuth: true, - oauthProviders: ['google', 'custom-sso'], - }, - signIn() { - return Promise.resolve({ - ok: false, - error: { code: 'unsupported', message: 'Password sign-in is not implemented.' }, - }); - }, - signUp() { - return Promise.resolve({ - ok: false, - error: { code: 'unsupported', message: 'Sign-up is not implemented.' }, - }); - }, - signOut() { - return Promise.resolve({ ok: true }); + ...createBaseAdapter(), + oauth: { + capabilities: { + providers: ['google', 'custom-sso'], + }, + startAuthorization(input) { + return Promise.resolve({ + ok: true, + data: { + attemptId: 'oauth-attempt-1', + provider: input.provider, + authorizationUrl: `https://auth.example.com/oauth/${input.provider}`, + redirectUri: input.redirectUri, + }, + }); + }, + completeAuthorization(input) { + if (input.response.type === 'cancelled') { + return Promise.resolve({ + ok: false, + status: 'cancelled', + provider: 'google', + reason: input.response.reason, + }); + } + + if (input.response.type === 'error') { + return Promise.resolve({ + ok: false, + status: 'error', + error: { + code: 'authorization_failed', + message: input.response.error.message, + stage: 'transport', + provider: 'google', + recoverable: true, + }, + }); + } + + return Promise.resolve({ + ok: true, + status: 'authenticated', + provider: 'google', + session, + }); + }, }, - getSession() { - return Promise.resolve({ ok: true, data: null }); + }; + + const started = await adapter.oauth?.startAuthorization({ + provider: 'google', + redirectUri: 'ankh-app://auth/callback', + scopes: ['openid', 'email', 'profile'], + queryParams: { prompt: 'select_account' }, + }); + + expect(adapter.oauth?.capabilities.providers).toEqual(['google', 'custom-sso']); + expect(started?.ok).toBe(true); + expect(started?.ok === true ? started.data.attemptId : undefined).toBe('oauth-attempt-1'); + expect(started?.ok === true ? started.data.redirectUri : undefined).toBe( + 'ankh-app://auth/callback', + ); + + const completed = await adapter.oauth?.completeAuthorization({ + attemptId: 'oauth-attempt-1', + response: { + type: 'callback', + url: 'ankh-app://auth/callback?code=opaque-code&state=opaque-state', }, - signInWithOAuth(input) { - return Promise.resolve({ - ok: true, - data: { - provider: input.provider, - url: `https://auth.example.com/oauth/${input.provider}`, - }, - }); + }); + + expect(completed?.status).toBe('authenticated'); + expect(completed?.ok === true ? completed.session : undefined).toEqual(session); + }); + + it('models user cancellation separately from OAuth failures', async () => { + const adapter: AuthAdapter = { + ...createBaseAdapter(), + oauth: { + capabilities: { providers: ['google'] }, + startAuthorization(input) { + return Promise.resolve({ + ok: true, + data: { + attemptId: 'oauth-attempt-2', + provider: input.provider, + authorizationUrl: 'https://auth.example.com/oauth/google', + redirectUri: input.redirectUri, + }, + }); + }, + completeAuthorization(input) { + if (input.response.type === 'cancelled') { + return Promise.resolve({ + ok: false, + status: 'cancelled', + provider: 'google', + reason: input.response.reason, + }); + } + + return Promise.resolve({ + ok: false, + status: 'error', + error: { + code: 'invalid_callback', + message: 'The OAuth callback is invalid.', + stage: 'callback', + provider: 'google', + recoverable: true, + }, + }); + }, }, }; - const result = await adapter.signInWithOAuth?.({ + const cancelled = await adapter.oauth?.completeAuthorization({ + attemptId: 'oauth-attempt-2', + response: { type: 'cancelled', reason: 'browser_dismissed' }, + }); + + expect(AUTH_OAUTH_CANCELLATION_REASONS).toContain('provider_denied'); + expect(AUTH_OAUTH_ERROR_CODES).toContain('pkce_mismatch'); + expect(cancelled).toEqual({ + ok: false, + status: 'cancelled', provider: 'google', - redirectTo: '/auth/callback', - scopes: ['openid', 'email', 'profile'], + reason: 'browser_dismissed', }); + }); + + it('removes the superseded URL-only OAuth adapter surface', () => { + const source = readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'auth.ts'), + 'utf8', + ); + const removedSymbols = [ + 'SignInWith' + 'OAuthInput', + 'AuthOAuth' + 'Redirect', + 'CompleteOAuth' + 'SignInInput', + 'signInWith' + 'OAuth', + 'completeOAuth' + 'SignIn', + 'supports' + 'OAuth', + 'oauth' + 'Providers', + ]; - expect(adapter.capabilities?.supportsOAuth).toBe(true); - expect(adapter.capabilities?.oauthProviders).toEqual(['google', 'custom-sso']); - expect(result?.ok).toBe(true); - expect(result?.ok === true ? result.data?.provider : undefined).toBe('google'); - expect(result?.ok === true ? result.data?.url : undefined).toContain('/oauth/google'); + for (const symbol of removedSymbols) { + expect(source).not.toContain(symbol); + } }); }); From e966d832d840fdfb5313aa10e927c44a605bf9ff Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:22:12 +0200 Subject: [PATCH 3/5] chore: add OAuth contracts changeset --- .changeset/phase3-canonical-oauth-contracts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/phase3-canonical-oauth-contracts.md diff --git a/.changeset/phase3-canonical-oauth-contracts.md b/.changeset/phase3-canonical-oauth-contracts.md new file mode 100644 index 0000000..9460d7a --- /dev/null +++ b/.changeset/phase3-canonical-oauth-contracts.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/contracts': major +--- + +Replace the optional URL-only OAuth adapter methods with one canonical provider-neutral OAuth capability that requires authorization start and callback completion, models transport cancellation and failures explicitly, and resolves successful OAuth sign-in to the existing `AuthSession` contract. From 05097c9226fc5ce0edf651735bbd6127429545be Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:29:09 +0200 Subject: [PATCH 4/5] refactor: tighten canonical OAuth contract --- src/auth.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index a2ba6e3..8d24042 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -80,7 +80,6 @@ export interface AuthOAuthProviderConfig { label?: string; enabled?: boolean; scopes?: string[]; - redirectTo?: string; queryParams?: Record; icon?: IconSpec; /** Logical server-side secret reference; raw credentials must never be stored here. */ @@ -130,17 +129,14 @@ export interface AuthAdapterError { cause?: unknown; } -export type AuthResult< - TData = void, - TError extends AuthAdapterError = AuthAdapterError, -> = +export type AuthResult = | { ok: true; data?: TData; } | { ok: false; - error: TError; + error: AuthAdapterError; }; export interface SignInInput { @@ -307,9 +303,7 @@ export interface AuthOAuthAdapter { readonly capabilities: AuthOAuthCapabilities; startAuthorization(input: StartOAuthAuthorizationInput): Promise; - completeAuthorization( - input: CompleteOAuthAuthorizationInput, - ): Promise; + completeAuthorization(input: CompleteOAuthAuthorizationInput): Promise; } export interface AuthAdapterCapabilities { From dc0073a47c87102d74e7593833438fc9fa29162d Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:29:55 +0200 Subject: [PATCH 5/5] test: use one canonical OAuth callback source --- src/auth-oauth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth-oauth.test.ts b/src/auth-oauth.test.ts index 8e129a1..97bdde2 100644 --- a/src/auth-oauth.test.ts +++ b/src/auth-oauth.test.ts @@ -73,7 +73,6 @@ describe('OAuth auth contracts', () => { id: 'custom-sso', label: 'Custom SSO', enabled: false, - redirectTo: '/auth/custom/callback', queryParams: { prompt: 'select_account', },