|
| 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