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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/phase3-canonical-oauth-contracts.md
Original file line number Diff line number Diff line change
@@ -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.
233 changes: 189 additions & 44 deletions src/auth-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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<AuthAdapter, 'oauth'> {
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 = {
Expand All @@ -27,7 +73,6 @@ describe('OAuth auth contracts', () => {
id: 'custom-sso',
label: 'Custom SSO',
enabled: false,
redirectTo: '/auth/custom/callback',
queryParams: {
prompt: 'select_account',
},
Expand All @@ -38,7 +83,6 @@ describe('OAuth auth contracts', () => {
const auth: AuthSpec = {
scope: 'global',
provider: 'supabase',
authorization: { kind: 'RBAC', engine: 'native' },
oauth,
};

Expand All @@ -48,56 +92,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);
}
});
});
Loading
Loading