Skip to content

Commit 221eab6

Browse files
os-zhuangclaude
andcommitted
test(dogfood): OIDC authorization-code flow e2e — real kernel, real token exchange
Drives authorize → code → token → userinfo against a bootStack kernel with OS_OIDC_PROVIDER_ENABLED, seeding the client row exactly the way cloud's seedPlatformSsoClient does (hashed secret, skip_consent, client_secret_basic, ≥1.7 /api/v1/auth/callback/<provider> redirect_uri). The token-exchange step is the hop that 500'd in production on the 1.7 schema drift; verified the test fails (2/3) when the authorization_code_id column is removed and passes (3/3) with the fix. Complements the static parity gate in plugin-auth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 758bf9e commit 221eab6

1 file changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* OIDC authorization-code flow — end-to-end against a REAL kernel.
5+
*
6+
* Regression proof for the @better-auth/oauth-provider 1.7 schema drift that
7+
* broke platform SSO: the token exchange 500'd with `table
8+
* sys_oauth_access_token has no column named authorizationCodeId` because the
9+
* 1.7 models gained fields the sys_oauth_* platform objects (and the
10+
* snake_case mappings in plugin-auth's auth-schema-config.ts) did not carry.
11+
* The static parity gate (plugin-auth's oauth-provider-schema-parity.test.ts)
12+
* catches missing columns at schema level; THIS test proves the actual wiring
13+
* — authorize → code → token — issues a token and lands the row, exactly the
14+
* hop that died in production.
15+
*
16+
* The client row mirrors what the cloud control plane seeds for per-project
17+
* platform SSO (`seedPlatformSsoClient`): confidential client,
18+
* client_secret_basic, skip_consent, hashed secret (SHA-256 → base64url — the
19+
* oauth-provider defaultHasher format).
20+
*/
21+
22+
import { createHash } from 'node:crypto';
23+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
24+
import showcaseStack from '@objectstack/example-showcase';
25+
import { bootStack, type VerifyStack } from '@objectstack/verify';
26+
27+
// Must be on before the AuthPlugin builds its plugin list (kernel.use during
28+
// bootStack) — this is the same switch cloud/objectstack dev deployments use.
29+
process.env.OS_OIDC_PROVIDER_ENABLED = 'true';
30+
31+
const CLIENT_ID = 'project_dogfood_env';
32+
const CLIENT_SECRET = 'dogfood-plaintext-secret';
33+
// better-auth ≥ 1.7 mounts genericOAuth RPs through the core social flow, so
34+
// the env-side callback is /api/v1/auth/callback/<provider> (the pre-1.7 form
35+
// was /api/v1/auth/oauth2/callback/<provider>).
36+
const REDIRECT_URI = 'https://env.example.com/api/v1/auth/callback/objectstack-cloud';
37+
38+
/** SHA-256 → base64url (no padding) — @better-auth/oauth-provider defaultHasher. */
39+
function hashSecret(plaintext: string): string {
40+
return createHash('sha256').update(plaintext)
41+
.digest('base64')
42+
.replace(/=+$/, '')
43+
.replace(/\+/g, '-')
44+
.replace(/\//g, '_');
45+
}
46+
47+
describe('OIDC authorization-code flow (oauth-provider 1.7)', () => {
48+
let stack: VerifyStack;
49+
let cookie: string;
50+
51+
beforeAll(async () => {
52+
stack = await bootStack(showcaseStack, {});
53+
54+
// Seed the OAuth client the way cloud's seedPlatformSsoClient does.
55+
const ql = await stack.kernel.getServiceAsync<any>('objectql');
56+
const nowIso = new Date().toISOString();
57+
await ql.insert('sys_oauth_application', {
58+
id: 'oauthc_dogfood_env',
59+
name: 'Dogfood Project',
60+
client_id: CLIENT_ID,
61+
client_secret: hashSecret(CLIENT_SECRET),
62+
type: 'web',
63+
redirect_uris: JSON.stringify([REDIRECT_URI]),
64+
grant_types: JSON.stringify(['authorization_code', 'refresh_token']),
65+
response_types: JSON.stringify(['code']),
66+
scopes: JSON.stringify(['openid', 'email', 'profile']),
67+
token_endpoint_auth_method: 'client_secret_basic',
68+
require_pkce: false,
69+
skip_consent: true,
70+
disabled: false,
71+
subject_type: 'public',
72+
created_at: nowIso,
73+
updated_at: nowIso,
74+
}, { context: { isSystem: true } });
75+
76+
// Browser-style session: sign in and carry the session COOKIE (the
77+
// authorize endpoint is hit by a redirected browser, not a bearer client).
78+
const res = await stack.api('/auth/sign-in/email', {
79+
method: 'POST',
80+
headers: { 'Content-Type': 'application/json' },
81+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
82+
});
83+
expect(res.ok).toBe(true);
84+
cookie = res.headers.getSetCookie?.().map((c: string) => c.split(';')[0]).join('; ')
85+
?? (res.headers.get('set-cookie') ?? '').split(';')[0];
86+
expect(cookie).toBeTruthy();
87+
}, 120_000);
88+
89+
afterAll(async () => { await stack?.stop?.(); });
90+
91+
let code: string;
92+
93+
it('authorize: skip_consent client gets an immediate code redirect', async () => {
94+
const qs = new URLSearchParams({
95+
response_type: 'code',
96+
client_id: CLIENT_ID,
97+
redirect_uri: REDIRECT_URI,
98+
scope: 'openid email profile',
99+
state: 'dogfood-state',
100+
});
101+
const res = await stack.api(`/auth/oauth2/authorize?${qs}`, {
102+
headers: { Cookie: cookie },
103+
redirect: 'manual',
104+
});
105+
expect([302, 303]).toContain(res.status);
106+
const location = res.headers.get('location') ?? '';
107+
expect(location.startsWith(REDIRECT_URI)).toBe(true);
108+
const url = new URL(location);
109+
expect(url.searchParams.get('state')).toBe('dogfood-state');
110+
expect(url.searchParams.get('error')).toBeNull();
111+
code = url.searchParams.get('code') ?? '';
112+
expect(code).toBeTruthy();
113+
});
114+
115+
it('token exchange succeeds and persists the access-token row (the 1.7-drift 500)', async () => {
116+
const res = await stack.api('/auth/oauth2/token', {
117+
method: 'POST',
118+
headers: {
119+
'Content-Type': 'application/x-www-form-urlencoded',
120+
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
121+
},
122+
body: new URLSearchParams({
123+
grant_type: 'authorization_code',
124+
code,
125+
redirect_uri: REDIRECT_URI,
126+
}).toString(),
127+
});
128+
const body: any = await res.json();
129+
// Pre-fix this was a 500: `table sys_oauth_access_token has no column
130+
// named authorizationCodeId`.
131+
expect(res.status, JSON.stringify(body)).toBe(200);
132+
expect(body.access_token).toBeTruthy();
133+
expect(body.id_token).toBeTruthy();
134+
135+
const ql = await stack.kernel.getServiceAsync<any>('objectql');
136+
const rows = await ql.find('sys_oauth_access_token', {
137+
filters: [['client_id', '=', CLIENT_ID]],
138+
context: { isSystem: true },
139+
});
140+
const list = Array.isArray(rows) ? rows : (rows?.records ?? []);
141+
expect(list.length).toBeGreaterThan(0);
142+
// The drift column: better-auth writes authorizationCodeId on every
143+
// code-grant issuance; the mapping must land it in snake_case.
144+
expect(list[0].authorization_code_id).toBeTruthy();
145+
});
146+
147+
it('userinfo answers with the signed-in subject', async () => {
148+
const tokenRes = await stack.api('/auth/oauth2/token', {
149+
method: 'POST',
150+
headers: {
151+
'Content-Type': 'application/x-www-form-urlencoded',
152+
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
153+
},
154+
body: new URLSearchParams({
155+
grant_type: 'authorization_code',
156+
// Single-use code was consumed above — mint a fresh one.
157+
code: await (async () => {
158+
const qs = new URLSearchParams({
159+
response_type: 'code',
160+
client_id: CLIENT_ID,
161+
redirect_uri: REDIRECT_URI,
162+
scope: 'openid email profile',
163+
});
164+
const res = await stack.api(`/auth/oauth2/authorize?${qs}`, {
165+
headers: { Cookie: cookie },
166+
redirect: 'manual',
167+
});
168+
return new URL(res.headers.get('location') ?? '').searchParams.get('code') ?? '';
169+
})(),
170+
redirect_uri: REDIRECT_URI,
171+
}).toString(),
172+
});
173+
const { access_token } = (await tokenRes.json()) as any;
174+
expect(access_token).toBeTruthy();
175+
176+
const res = await stack.api('/auth/oauth2/userinfo', {
177+
headers: { Authorization: `Bearer ${access_token}` },
178+
});
179+
const info: any = await res.json();
180+
expect(res.status, JSON.stringify(info)).toBe(200);
181+
expect(info.email).toBe('admin@objectos.ai');
182+
});
183+
});

0 commit comments

Comments
 (0)