@ankhorage/contracts 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
1
5
  import { describe, expect, it } from 'bun:test';
2
6
 
3
7
  import {
8
+ AUTH_OAUTH_CANCELLATION_REASONS,
9
+ AUTH_OAUTH_ERROR_CODES,
4
10
  AUTH_OAUTH_PROVIDER_IDS,
5
11
  type AuthAdapter,
6
12
  type AuthOAuthConfig,
13
+ type AuthSession,
7
14
  type AuthSpec,
8
15
  } from './index';
9
16
 
17
+ const session: AuthSession = {
18
+ accessToken: 'access-token',
19
+ refreshToken: 'refresh-token',
20
+ user: {
21
+ id: 'user-1',
22
+ email: 'person@example.com',
23
+ },
24
+ };
25
+
26
+ function createBaseAdapter(): Omit<AuthAdapter, 'oauth'> {
27
+ return {
28
+ capabilities: {
29
+ signInIdentifiers: ['email'],
30
+ supportsSignUp: true,
31
+ supportsPasswordReset: true,
32
+ supportsOtp: true,
33
+ supportsSessionRefresh: true,
34
+ },
35
+ signIn() {
36
+ return Promise.resolve({
37
+ ok: false,
38
+ error: { code: 'unsupported', message: 'Password sign-in is not implemented.' },
39
+ });
40
+ },
41
+ signUp() {
42
+ return Promise.resolve({
43
+ ok: false,
44
+ error: { code: 'unsupported', message: 'Sign-up is not implemented.' },
45
+ });
46
+ },
47
+ signOut() {
48
+ return Promise.resolve({ ok: true });
49
+ },
50
+ getSession() {
51
+ return Promise.resolve({ ok: true, data: null });
52
+ },
53
+ };
54
+ }
55
+
10
56
  describe('OAuth auth contracts', () => {
11
57
  it('accepts provider-neutral OAuth provider config on auth specs', () => {
12
58
  const oauth: AuthOAuthConfig = {
@@ -27,7 +73,6 @@ describe('OAuth auth contracts', () => {
27
73
  id: 'custom-sso',
28
74
  label: 'Custom SSO',
29
75
  enabled: false,
30
- redirectTo: '/auth/custom/callback',
31
76
  queryParams: {
32
77
  prompt: 'select_account',
33
78
  },
@@ -38,7 +83,6 @@ describe('OAuth auth contracts', () => {
38
83
  const auth: AuthSpec = {
39
84
  scope: 'global',
40
85
  provider: 'supabase',
41
- authorization: { kind: 'RBAC', engine: 'native' },
42
86
  oauth,
43
87
  };
44
88
 
@@ -48,56 +92,157 @@ describe('OAuth auth contracts', () => {
48
92
  expect(auth.oauth?.providers[1]?.enabled).toBe(false);
49
93
  });
50
94
 
51
- it('accepts optional OAuth adapter capabilities and redirect flow methods', async () => {
95
+ it('advertises OAuth only through one complete start and completion capability', async () => {
52
96
  const adapter: AuthAdapter = {
53
- capabilities: {
54
- signInIdentifiers: ['email'],
55
- supportsSignUp: true,
56
- supportsPasswordReset: true,
57
- supportsOtp: true,
58
- supportsSessionRefresh: true,
59
- supportsOAuth: true,
60
- oauthProviders: ['google', 'custom-sso'],
61
- },
62
- signIn() {
63
- return Promise.resolve({
64
- ok: false,
65
- error: { code: 'unsupported', message: 'Password sign-in is not implemented.' },
66
- });
67
- },
68
- signUp() {
69
- return Promise.resolve({
70
- ok: false,
71
- error: { code: 'unsupported', message: 'Sign-up is not implemented.' },
72
- });
73
- },
74
- signOut() {
75
- return Promise.resolve({ ok: true });
97
+ ...createBaseAdapter(),
98
+ oauth: {
99
+ capabilities: {
100
+ providers: ['google', 'custom-sso'],
101
+ },
102
+ startAuthorization(input) {
103
+ return Promise.resolve({
104
+ ok: true,
105
+ data: {
106
+ attemptId: 'oauth-attempt-1',
107
+ provider: input.provider,
108
+ authorizationUrl: `https://auth.example.com/oauth/${input.provider}`,
109
+ redirectUri: input.redirectUri,
110
+ },
111
+ });
112
+ },
113
+ completeAuthorization(input) {
114
+ if (input.response.type === 'cancelled') {
115
+ return Promise.resolve({
116
+ ok: false,
117
+ status: 'cancelled',
118
+ provider: 'google',
119
+ reason: input.response.reason,
120
+ });
121
+ }
122
+
123
+ if (input.response.type === 'error') {
124
+ return Promise.resolve({
125
+ ok: false,
126
+ status: 'error',
127
+ error: {
128
+ code: 'authorization_failed',
129
+ message: input.response.error.message,
130
+ stage: 'transport',
131
+ provider: 'google',
132
+ recoverable: true,
133
+ },
134
+ });
135
+ }
136
+
137
+ return Promise.resolve({
138
+ ok: true,
139
+ status: 'authenticated',
140
+ provider: 'google',
141
+ session,
142
+ });
143
+ },
76
144
  },
77
- getSession() {
78
- return Promise.resolve({ ok: true, data: null });
145
+ };
146
+
147
+ const started = await adapter.oauth?.startAuthorization({
148
+ provider: 'google',
149
+ redirectUri: 'ankh-app://auth/callback',
150
+ scopes: ['openid', 'email', 'profile'],
151
+ queryParams: { prompt: 'select_account' },
152
+ });
153
+
154
+ expect(adapter.oauth?.capabilities.providers).toEqual(['google', 'custom-sso']);
155
+ expect(started?.ok).toBe(true);
156
+ expect(started?.ok === true ? started.data.attemptId : undefined).toBe('oauth-attempt-1');
157
+ expect(started?.ok === true ? started.data.redirectUri : undefined).toBe(
158
+ 'ankh-app://auth/callback',
159
+ );
160
+
161
+ const completed = await adapter.oauth?.completeAuthorization({
162
+ attemptId: 'oauth-attempt-1',
163
+ response: {
164
+ type: 'callback',
165
+ url: 'ankh-app://auth/callback?code=opaque-code&state=opaque-state',
79
166
  },
80
- signInWithOAuth(input) {
81
- return Promise.resolve({
82
- ok: true,
83
- data: {
84
- provider: input.provider,
85
- url: `https://auth.example.com/oauth/${input.provider}`,
86
- },
87
- });
167
+ });
168
+
169
+ expect(completed?.status).toBe('authenticated');
170
+ expect(completed?.ok === true ? completed.session : undefined).toEqual(session);
171
+ });
172
+
173
+ it('models user cancellation separately from OAuth failures', async () => {
174
+ const adapter: AuthAdapter = {
175
+ ...createBaseAdapter(),
176
+ oauth: {
177
+ capabilities: { providers: ['google'] },
178
+ startAuthorization(input) {
179
+ return Promise.resolve({
180
+ ok: true,
181
+ data: {
182
+ attemptId: 'oauth-attempt-2',
183
+ provider: input.provider,
184
+ authorizationUrl: 'https://auth.example.com/oauth/google',
185
+ redirectUri: input.redirectUri,
186
+ },
187
+ });
188
+ },
189
+ completeAuthorization(input) {
190
+ if (input.response.type === 'cancelled') {
191
+ return Promise.resolve({
192
+ ok: false,
193
+ status: 'cancelled',
194
+ provider: 'google',
195
+ reason: input.response.reason,
196
+ });
197
+ }
198
+
199
+ return Promise.resolve({
200
+ ok: false,
201
+ status: 'error',
202
+ error: {
203
+ code: 'invalid_callback',
204
+ message: 'The OAuth callback is invalid.',
205
+ stage: 'callback',
206
+ provider: 'google',
207
+ recoverable: true,
208
+ },
209
+ });
210
+ },
88
211
  },
89
212
  };
90
213
 
91
- const result = await adapter.signInWithOAuth?.({
214
+ const cancelled = await adapter.oauth?.completeAuthorization({
215
+ attemptId: 'oauth-attempt-2',
216
+ response: { type: 'cancelled', reason: 'browser_dismissed' },
217
+ });
218
+
219
+ expect(AUTH_OAUTH_CANCELLATION_REASONS).toContain('provider_denied');
220
+ expect(AUTH_OAUTH_ERROR_CODES).toContain('pkce_mismatch');
221
+ expect(cancelled).toEqual({
222
+ ok: false,
223
+ status: 'cancelled',
92
224
  provider: 'google',
93
- redirectTo: '/auth/callback',
94
- scopes: ['openid', 'email', 'profile'],
225
+ reason: 'browser_dismissed',
95
226
  });
227
+ });
228
+
229
+ it('removes the superseded URL-only OAuth adapter surface', () => {
230
+ const source = readFileSync(
231
+ path.join(path.dirname(fileURLToPath(import.meta.url)), 'auth.ts'),
232
+ 'utf8',
233
+ );
234
+ const removedSymbols = [
235
+ 'SignInWith' + 'OAuthInput',
236
+ 'AuthOAuth' + 'Redirect',
237
+ 'CompleteOAuth' + 'SignInInput',
238
+ 'signInWith' + 'OAuth',
239
+ 'completeOAuth' + 'SignIn',
240
+ 'supports' + 'OAuth',
241
+ 'oauth' + 'Providers',
242
+ ];
96
243
 
97
- expect(adapter.capabilities?.supportsOAuth).toBe(true);
98
- expect(adapter.capabilities?.oauthProviders).toEqual(['google', 'custom-sso']);
99
- expect(result?.ok).toBe(true);
100
- expect(result?.ok === true ? result.data?.provider : undefined).toBe('google');
101
- expect(result?.ok === true ? result.data?.url : undefined).toContain('/oauth/google');
244
+ for (const symbol of removedSymbols) {
245
+ expect(source).not.toContain(symbol);
246
+ }
102
247
  });
103
248
  });
package/src/auth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SecretRef } from './secrets';
1
2
  import type { IconSpec } from './types';
2
3
 
3
4
  export const AUTH_IDENTIFIER_KINDS = ['email', 'phone', 'username'] as const;
@@ -79,9 +80,10 @@ export interface AuthOAuthProviderConfig {
79
80
  label?: string;
80
81
  enabled?: boolean;
81
82
  scopes?: string[];
82
- redirectTo?: string;
83
83
  queryParams?: Record<string, string>;
84
84
  icon?: IconSpec;
85
+ /** Logical server-side secret reference; raw credentials must never be stored here. */
86
+ credentialsRef?: SecretRef;
85
87
  }
86
88
 
87
89
  export interface AuthOAuthConfig {
@@ -169,22 +171,139 @@ export interface VerifyOtpInput {
169
171
  metadata?: Record<string, unknown>;
170
172
  }
171
173
 
172
- export interface SignInWithOAuthInput {
174
+ export const AUTH_OAUTH_ERROR_STAGES = [
175
+ 'start',
176
+ 'transport',
177
+ 'callback',
178
+ 'exchange',
179
+ 'session',
180
+ 'profile',
181
+ ] as const;
182
+ export type AuthOAuthErrorStage = (typeof AUTH_OAUTH_ERROR_STAGES)[number];
183
+
184
+ export const AUTH_OAUTH_ERROR_CODES = [
185
+ 'oauth_unavailable',
186
+ 'provider_disabled',
187
+ 'provider_misconfigured',
188
+ 'invalid_redirect_uri',
189
+ 'authorization_failed',
190
+ 'authorization_attempt_not_found',
191
+ 'invalid_callback',
192
+ 'state_mismatch',
193
+ 'pkce_mismatch',
194
+ 'callback_already_completed',
195
+ 'code_exchange_failed',
196
+ 'network_error',
197
+ 'session_persistence_failed',
198
+ 'profile_creation_failed',
199
+ 'provider_error',
200
+ ] as const;
201
+ export type AuthOAuthErrorCode = (typeof AUTH_OAUTH_ERROR_CODES)[number];
202
+
203
+ export interface AuthOAuthError extends AuthAdapterError {
204
+ code: AuthOAuthErrorCode;
205
+ stage: AuthOAuthErrorStage;
206
+ provider?: AuthOAuthProviderId;
207
+ recoverable: boolean;
208
+ }
209
+
210
+ export const AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS = [
211
+ 'user_cancelled',
212
+ 'browser_dismissed',
213
+ ] as const;
214
+ export type AuthOAuthTransportCancellationReason =
215
+ (typeof AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS)[number];
216
+
217
+ export const AUTH_OAUTH_CANCELLATION_REASONS = [
218
+ ...AUTH_OAUTH_TRANSPORT_CANCELLATION_REASONS,
219
+ 'provider_denied',
220
+ ] as const;
221
+ export type AuthOAuthCancellationReason = (typeof AUTH_OAUTH_CANCELLATION_REASONS)[number];
222
+
223
+ export const AUTH_OAUTH_TRANSPORT_ERROR_CODES = [
224
+ 'browser_unavailable',
225
+ 'transport_failed',
226
+ ] as const;
227
+ export type AuthOAuthTransportErrorCode = (typeof AUTH_OAUTH_TRANSPORT_ERROR_CODES)[number];
228
+
229
+ export interface AuthOAuthTransportError {
230
+ code: AuthOAuthTransportErrorCode;
231
+ message: string;
232
+ cause?: unknown;
233
+ }
234
+
235
+ export interface StartOAuthAuthorizationInput {
173
236
  provider: AuthOAuthProviderId;
174
- redirectTo?: string;
175
- scopes?: string[];
176
- queryParams?: Record<string, string>;
177
- metadata?: Record<string, unknown>;
237
+ redirectUri: string;
238
+ scopes?: readonly string[];
239
+ queryParams?: Readonly<Record<string, string>>;
178
240
  }
179
241
 
180
- export interface AuthOAuthRedirect {
242
+ export interface AuthOAuthAuthorizationRequest {
243
+ attemptId: string;
181
244
  provider: AuthOAuthProviderId;
182
- url: string;
245
+ authorizationUrl: string;
246
+ redirectUri: string;
183
247
  }
184
248
 
185
- export interface CompleteOAuthSignInInput {
186
- url: string;
187
- redirectTo?: string;
249
+ export type AuthOAuthStartResult =
250
+ | {
251
+ ok: true;
252
+ data: AuthOAuthAuthorizationRequest;
253
+ }
254
+ | {
255
+ ok: false;
256
+ error: AuthOAuthError;
257
+ };
258
+
259
+ export type AuthOAuthAuthorizationResponse =
260
+ | {
261
+ type: 'callback';
262
+ url: string;
263
+ }
264
+ | {
265
+ type: 'cancelled';
266
+ reason: AuthOAuthTransportCancellationReason;
267
+ }
268
+ | {
269
+ type: 'error';
270
+ error: AuthOAuthTransportError;
271
+ };
272
+
273
+ export interface CompleteOAuthAuthorizationInput {
274
+ attemptId: string;
275
+ response: AuthOAuthAuthorizationResponse;
276
+ }
277
+
278
+ export type AuthOAuthCompletionResult =
279
+ | {
280
+ ok: true;
281
+ status: 'authenticated';
282
+ provider: AuthOAuthProviderId;
283
+ session: AuthSession;
284
+ }
285
+ | {
286
+ ok: false;
287
+ status: 'cancelled';
288
+ provider: AuthOAuthProviderId;
289
+ reason: AuthOAuthCancellationReason;
290
+ }
291
+ | {
292
+ ok: false;
293
+ status: 'error';
294
+ error: AuthOAuthError;
295
+ };
296
+
297
+ export interface AuthOAuthCapabilities {
298
+ /** Enabled providers for which start and callback completion are operational. */
299
+ providers: readonly [AuthOAuthProviderId, ...AuthOAuthProviderId[]];
300
+ }
301
+
302
+ export interface AuthOAuthAdapter {
303
+ readonly capabilities: AuthOAuthCapabilities;
304
+
305
+ startAuthorization(input: StartOAuthAuthorizationInput): Promise<AuthOAuthStartResult>;
306
+ completeAuthorization(input: CompleteOAuthAuthorizationInput): Promise<AuthOAuthCompletionResult>;
188
307
  }
189
308
 
190
309
  export interface AuthAdapterCapabilities {
@@ -193,12 +312,15 @@ export interface AuthAdapterCapabilities {
193
312
  supportsPasswordReset: boolean;
194
313
  supportsOtp: boolean;
195
314
  supportsSessionRefresh: boolean;
196
- supportsOAuth?: boolean;
197
- oauthProviders?: AuthOAuthProviderId[];
198
315
  }
199
316
 
200
317
  export interface AuthAdapter {
201
318
  readonly capabilities?: AuthAdapterCapabilities;
319
+ /**
320
+ * Presence is the canonical OAuth capability signal. When present, both authorization start and
321
+ * callback completion are mandatory and operational for every advertised provider.
322
+ */
323
+ readonly oauth?: AuthOAuthAdapter;
202
324
 
203
325
  signIn(input: SignInInput): Promise<AuthResult<AuthSession>>;
204
326
  signUp(input: SignUpInput): Promise<AuthResult<AuthSession | AuthUser>>;
@@ -209,7 +331,4 @@ export interface AuthAdapter {
209
331
 
210
332
  requestPasswordReset?(input: PasswordResetInput): Promise<AuthResult>;
211
333
  verifyOtp?(input: VerifyOtpInput): Promise<AuthResult<AuthSession>>;
212
-
213
- signInWithOAuth?(input: SignInWithOAuthInput): Promise<AuthResult<AuthOAuthRedirect>>;
214
- completeOAuthSignIn?(input: CompleteOAuthSignInInput): Promise<AuthResult<AuthSession>>;
215
334
  }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from './db';
6
6
  export * from './nutrition';
7
7
  export * from './requirements';
8
8
  export * from './runtimeCallbacks';
9
+ export * from './secretManifest';
10
+ export * from './secrets';
9
11
  export * from './state';
10
12
  export * from './storage';
11
13
  export * from './types';
@@ -0,0 +1,12 @@
1
+ import type { SecretStoreProvider } from './secrets';
2
+
3
+ export interface InfraSecretStoreSpec {
4
+ provider: SecretStoreProvider;
5
+ }
6
+
7
+ declare module './types' {
8
+ interface InfraManifest {
9
+ /** Non-secret provider selection. Bootstrap credentials stay in trusted environment config. */
10
+ secretStore?: InfraSecretStoreSpec;
11
+ }
12
+ }
@@ -0,0 +1,88 @@
1
+ import './secretManifest';
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import type { AuthOAuthProviderConfig } from './auth';
6
+ import {
7
+ findForbiddenInlineSecretFields,
8
+ normalizeSecretRef,
9
+ normalizeSecretScope,
10
+ validateSecretPayload,
11
+ } from './secrets';
12
+ import type { InfraManifest } from './types';
13
+
14
+ describe('secret-store contracts', () => {
15
+ test('normalizes logical secret references', () => {
16
+ expect(normalizeSecretRef('/auth//oauth/google/')).toEqual({
17
+ ok: true,
18
+ data: 'auth/oauth/google',
19
+ });
20
+ });
21
+
22
+ test('rejects invalid secret references', () => {
23
+ expect(normalizeSecretRef('Auth OAuth/Google')).toEqual({
24
+ ok: false,
25
+ error: {
26
+ code: 'invalid_reference',
27
+ message:
28
+ 'Secret reference must contain lowercase path segments using letters, numbers, dots, underscores, or hyphens.',
29
+ },
30
+ });
31
+ });
32
+
33
+ test('normalizes project and environment scope', () => {
34
+ expect(normalizeSecretScope({ projectId: ' scanner ', environment: ' local ' })).toEqual({
35
+ ok: true,
36
+ data: { projectId: 'scanner', environment: 'local' },
37
+ });
38
+ });
39
+
40
+ test('validates non-empty string payloads without exposing values', () => {
41
+ expect(validateSecretPayload({ clientId: 'id', clientSecret: 'secret' })).toEqual({
42
+ ok: true,
43
+ data: { clientId: 'id', clientSecret: 'secret' },
44
+ });
45
+
46
+ const result = validateSecretPayload({ clientSecret: '' });
47
+ expect(result.ok).toBe(false);
48
+ if (!result.ok) {
49
+ expect(result.error.code).toBe('invalid_payload');
50
+ expect(result.error.message).not.toContain('secret-value');
51
+ }
52
+ });
53
+
54
+ test('detects forbidden inline secret fields in manifest-shaped provider config', () => {
55
+ expect(
56
+ findForbiddenInlineSecretFields({
57
+ id: 'google',
58
+ credentialsRef: 'auth/oauth/google',
59
+ clientSecret: 'sentinel-secret-value',
60
+ }),
61
+ ).toEqual(['clientSecret']);
62
+ });
63
+
64
+ test('supports OAuth credential references and canonical infra provider selection', () => {
65
+ const provider: AuthOAuthProviderConfig = {
66
+ id: 'google',
67
+ enabled: true,
68
+ credentialsRef: 'auth/oauth/google',
69
+ };
70
+
71
+ const infra: InfraManifest = {
72
+ plugins: [],
73
+ secretStore: { provider: 'supabase-vault' },
74
+ auth: {
75
+ scope: 'global',
76
+ provider: 'supabase',
77
+ oauth: {
78
+ enabled: true,
79
+ callbackRoute: '/auth/callback',
80
+ providers: [provider],
81
+ },
82
+ },
83
+ };
84
+
85
+ expect(infra.secretStore?.provider).toBe('supabase-vault');
86
+ expect(infra.auth?.oauth?.providers[0]?.credentialsRef).toBe('auth/oauth/google');
87
+ });
88
+ });