@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.
- package/CHANGELOG.md +12 -0
- package/dist/auth.d.ts +80 -14
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +37 -0
- package/dist/auth.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/secretManifest.d.ts +11 -0
- package/dist/secretManifest.d.ts.map +1 -0
- package/dist/secretManifest.js +2 -0
- package/dist/secretManifest.js.map +1 -0
- package/dist/secrets.d.ts +85 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +83 -0
- package/dist/secrets.js.map +1 -0
- package/package.json +8 -2
- package/src/auth-oauth.test.ts +189 -44
- package/src/auth.ts +135 -16
- package/src/index.ts +2 -0
- package/src/secretManifest.ts +12 -0
- package/src/secrets.test.ts +88 -0
- package/src/secrets.ts +188 -0
package/src/auth-oauth.test.ts
CHANGED
|
@@ -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('
|
|
95
|
+
it('advertises OAuth only through one complete start and completion capability', async () => {
|
|
52
96
|
const adapter: AuthAdapter = {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
242
|
+
export interface AuthOAuthAuthorizationRequest {
|
|
243
|
+
attemptId: string;
|
|
181
244
|
provider: AuthOAuthProviderId;
|
|
182
|
-
|
|
245
|
+
authorizationUrl: string;
|
|
246
|
+
redirectUri: string;
|
|
183
247
|
}
|
|
184
248
|
|
|
185
|
-
export
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
});
|