crossly.client.auth.service 0.0.1

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.
Files changed (66) hide show
  1. package/.vscode/launch.json +95 -0
  2. package/.vscode/settings.json +24 -0
  3. package/.vscode/tasks.json +34 -0
  4. package/README.md +38 -0
  5. package/contracts/README.md +28 -0
  6. package/contracts/package-lock.json +30 -0
  7. package/contracts/package.json +46 -0
  8. package/contracts/src/index.ts +53 -0
  9. package/contracts/tsconfig.json +22 -0
  10. package/dist/app.js +52 -0
  11. package/dist/app.js.map +1 -0
  12. package/dist/config.js +29 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/controllers/authController.js +213 -0
  15. package/dist/controllers/authController.js.map +1 -0
  16. package/dist/createApp.js +29 -0
  17. package/dist/createApp.js.map +1 -0
  18. package/dist/db/migrate.js +96 -0
  19. package/dist/db/migrate.js.map +1 -0
  20. package/dist/managers/authManager.js +79 -0
  21. package/dist/managers/authManager.js.map +1 -0
  22. package/dist/managers/types.js +2 -0
  23. package/dist/managers/types.js.map +1 -0
  24. package/dist/oidc/googleProvider.js +54 -0
  25. package/dist/oidc/googleProvider.js.map +1 -0
  26. package/dist/oidc/notConfiguredOidcProvider.js +14 -0
  27. package/dist/oidc/notConfiguredOidcProvider.js.map +1 -0
  28. package/dist/oidc/types.js +2 -0
  29. package/dist/oidc/types.js.map +1 -0
  30. package/dist/repository/inMemoryClientRepository.js +60 -0
  31. package/dist/repository/inMemoryClientRepository.js.map +1 -0
  32. package/dist/repository/pgClientRepository.js +45 -0
  33. package/dist/repository/pgClientRepository.js.map +1 -0
  34. package/dist/repository/types.js +2 -0
  35. package/dist/repository/types.js.map +1 -0
  36. package/dist/signer/jwtSigner.js +36 -0
  37. package/dist/signer/jwtSigner.js.map +1 -0
  38. package/dist/signer/types.js +2 -0
  39. package/dist/signer/types.js.map +1 -0
  40. package/docker-compose.yml +25 -0
  41. package/migrations/0001_create_clients.sql +16 -0
  42. package/package.json +50 -0
  43. package/src/app.ts +61 -0
  44. package/src/config.ts +51 -0
  45. package/src/controllers/authController.ts +237 -0
  46. package/src/createApp.ts +45 -0
  47. package/src/db/migrate.ts +106 -0
  48. package/src/managers/authManager.ts +105 -0
  49. package/src/managers/types.ts +59 -0
  50. package/src/oidc/googleProvider.ts +72 -0
  51. package/src/oidc/notConfiguredOidcProvider.ts +16 -0
  52. package/src/oidc/types.ts +41 -0
  53. package/src/repository/inMemoryClientRepository.ts +72 -0
  54. package/src/repository/pgClientRepository.ts +75 -0
  55. package/src/repository/types.ts +50 -0
  56. package/src/signer/jwtSigner.ts +49 -0
  57. package/src/signer/types.ts +14 -0
  58. package/tests/integration/auth.api.test.ts +212 -0
  59. package/tests/integration/fakeOidcProvider.ts +32 -0
  60. package/tests/unit/authManager.test.ts +87 -0
  61. package/tests/unit/clientRepository.test.ts +115 -0
  62. package/tests/unit/jwtSigner.test.ts +42 -0
  63. package/tests/unit/resolveLogin.test.ts +86 -0
  64. package/tsconfig.build.json +11 -0
  65. package/tsconfig.json +24 -0
  66. package/tsconfig.test.json +25 -0
@@ -0,0 +1,213 @@
1
+ import { Router } from 'express';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { OAUTH_COOKIE, SESSION_COOKIE } from '../config.js';
4
+ /** The OAuth state/PKCE cookie only needs to outlive the redirect round-trip. */
5
+ const OAUTH_COOKIE_TTL_MS = 10 * 60 * 1000;
6
+ /**
7
+ * HTTP surface for client authentication (BFF cookie model).
8
+ *
9
+ * The session JWT rides in an httpOnly cookie ({@link SESSION_COOKIE}) — the
10
+ * browser never sees it in JS — so endpoints derive identity from the cookie and
11
+ * the UI learns who it is via `GET /auth/me`.
12
+ *
13
+ * Mounted under `/auth`:
14
+ * POST /auth/guest -> mint a guest session; set the session cookie
15
+ * POST /auth/refresh -> roll the session cookie forward
16
+ * GET /auth/validate -> verify the cookie; X-Client-Id / X-Guest (gateway ForwardAuth)
17
+ * GET /auth/me -> { clientId, guest, email? } for the UI
18
+ * GET /auth/login -> begin Authorization Code + PKCE; redirect to the provider
19
+ * GET /auth/callback -> finish login; promote the guest in place; set the session cookie
20
+ * POST /auth/logout -> clear the session cookie (UI then mints a fresh guest)
21
+ */
22
+ export class AuthController {
23
+ constructor(manager, oidc, config) {
24
+ this.manager = manager;
25
+ this.oidc = oidc;
26
+ this.config = config;
27
+ this.createGuestSession = async (req, res) => {
28
+ // Idempotent: if a valid session already exists (guest OR authenticated),
29
+ // return it instead of minting a new guest — so this can never downgrade a
30
+ // logged-in user or churn an existing guest. To abandon a session, use
31
+ // POST /auth/logout (which clears the cookie); the next call then creates one.
32
+ const existing = this.sessionToken(req);
33
+ if (existing) {
34
+ try {
35
+ const claims = await this.manager.validate(existing);
36
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
37
+ return;
38
+ }
39
+ catch {
40
+ // Invalid/expired cookie -> fall through and mint a fresh guest.
41
+ }
42
+ }
43
+ const session = await this.manager.createGuestSession();
44
+ this.setSessionCookie(res, session.token, session.expiresAt);
45
+ res.status(201).json({ clientId: session.clientId, guest: true });
46
+ };
47
+ this.refreshSession = async (req, res) => {
48
+ const token = this.sessionToken(req);
49
+ if (!token) {
50
+ res.status(401).json({ error: 'no session' });
51
+ return;
52
+ }
53
+ try {
54
+ const session = await this.manager.refreshSession(token);
55
+ this.setSessionCookie(res, session.token, session.expiresAt);
56
+ const claims = await this.manager.validate(session.token);
57
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
58
+ }
59
+ catch {
60
+ this.clearSessionCookie(res);
61
+ res.status(401).json({ error: 'invalid or expired session' });
62
+ }
63
+ };
64
+ this.validate = async (req, res) => {
65
+ const token = this.sessionToken(req);
66
+ if (!token) {
67
+ res.status(401).json({ error: 'no session' });
68
+ return;
69
+ }
70
+ try {
71
+ const claims = await this.manager.validate(token);
72
+ // The gateway (ForwardAuth) copies these onto the proxied request so
73
+ // downstream services receive a trusted identity they didn't have to verify.
74
+ res.setHeader('X-Client-Id', claims.sub);
75
+ res.setHeader('X-Guest', String(claims.guest));
76
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
77
+ }
78
+ catch {
79
+ res.status(401).json({ error: 'invalid or expired session' });
80
+ }
81
+ };
82
+ this.me = async (req, res) => {
83
+ const token = this.sessionToken(req);
84
+ if (!token) {
85
+ res.status(401).json({ error: 'no session' });
86
+ return;
87
+ }
88
+ try {
89
+ const info = await this.manager.describeSession(token);
90
+ res.status(200).json(info);
91
+ }
92
+ catch {
93
+ res.status(401).json({ error: 'invalid or expired session' });
94
+ }
95
+ };
96
+ this.login = async (_req, res) => {
97
+ // Generic, provider-agnostic CSRF state + PKCE verifier; the provider turns
98
+ // the verifier into a code_challenge when building the authorize URL.
99
+ const state = randomBytes(16).toString('base64url');
100
+ const codeVerifier = randomBytes(32).toString('base64url');
101
+ try {
102
+ const url = await this.oidc.authorizeUrl(state, codeVerifier);
103
+ res.cookie(OAUTH_COOKIE, JSON.stringify({ state, codeVerifier }), {
104
+ ...this.baseCookieOptions(),
105
+ // path '/' (not the mount path) so it survives wherever the auth
106
+ // routes are mounted; it's short-lived, signed and single-use.
107
+ maxAge: OAUTH_COOKIE_TTL_MS,
108
+ signed: true,
109
+ });
110
+ res.redirect(302, url);
111
+ }
112
+ catch {
113
+ res.status(503).json({ error: 'login not configured' });
114
+ }
115
+ };
116
+ this.callback = async (req, res) => {
117
+ const code = typeof req.query.code === 'string' ? req.query.code : undefined;
118
+ const state = typeof req.query.state === 'string' ? req.query.state : undefined;
119
+ const stash = this.readOauthCookie(req);
120
+ // The OAuth cookie is single-use.
121
+ res.clearCookie(OAUTH_COOKIE, { path: '/' });
122
+ if (!code || !state || !stash || stash.state !== state) {
123
+ res.status(400).json({ error: 'invalid oauth callback' });
124
+ return;
125
+ }
126
+ try {
127
+ // Pass ALL callback params (code, state, iss, …) through to the provider.
128
+ const queryIndex = req.originalUrl.indexOf('?');
129
+ const callbackParams = new URLSearchParams(queryIndex >= 0 ? req.originalUrl.slice(queryIndex + 1) : '');
130
+ const identity = await this.oidc.exchangeCode(callbackParams, stash.codeVerifier);
131
+ // Promote the current guest in place if one is present on this device.
132
+ const guestClientId = await this.guestClientId(req);
133
+ const resolved = await this.manager.resolveLogin(identity, guestClientId);
134
+ const session = await this.manager.createAuthenticatedSession(resolved.clientId);
135
+ // Overwriting the session cookie also "clears" the guest session.
136
+ this.setSessionCookie(res, session.token, session.expiresAt);
137
+ res.redirect(302, this.config.uiRedirectUrl);
138
+ }
139
+ catch (e) {
140
+ console.log(e);
141
+ res.status(400).json({ error: 'login failed' });
142
+ }
143
+ };
144
+ this.logout = async (_req, res) => {
145
+ this.clearSessionCookie(res);
146
+ res.status(204).end();
147
+ };
148
+ this.router = Router();
149
+ this.registerRoutes();
150
+ }
151
+ registerRoutes() {
152
+ this.router.post('/guest', this.createGuestSession);
153
+ this.router.post('/refresh', this.refreshSession);
154
+ this.router.get('/validate', this.validate);
155
+ this.router.get('/me', this.me);
156
+ this.router.get('/login', this.login);
157
+ this.router.get('/callback', this.callback);
158
+ this.router.post('/logout', this.logout);
159
+ }
160
+ // --- cookie helpers ---
161
+ baseCookieOptions() {
162
+ return {
163
+ httpOnly: true,
164
+ sameSite: 'lax',
165
+ secure: this.config.secureCookies,
166
+ path: '/',
167
+ };
168
+ }
169
+ setSessionCookie(res, token, expiresAt) {
170
+ res.cookie(SESSION_COOKIE, token, {
171
+ ...this.baseCookieOptions(),
172
+ maxAge: Math.max(0, expiresAt * 1000 - Date.now()),
173
+ });
174
+ }
175
+ clearSessionCookie(res) {
176
+ res.clearCookie(SESSION_COOKIE, { path: '/' });
177
+ }
178
+ sessionToken(req) {
179
+ const value = req.cookies?.[SESSION_COOKIE];
180
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
181
+ }
182
+ readOauthCookie(req) {
183
+ const raw = req.signedCookies?.[OAUTH_COOKIE];
184
+ if (typeof raw !== 'string') {
185
+ return undefined;
186
+ }
187
+ try {
188
+ const parsed = JSON.parse(raw);
189
+ if (typeof parsed.state === 'string' && typeof parsed.codeVerifier === 'string') {
190
+ return { state: parsed.state, codeVerifier: parsed.codeVerifier };
191
+ }
192
+ }
193
+ catch {
194
+ // fall through
195
+ }
196
+ return undefined;
197
+ }
198
+ /** The current session's clientId iff it is a (still valid) guest — for promote-in-place. */
199
+ async guestClientId(req) {
200
+ const token = this.sessionToken(req);
201
+ if (!token) {
202
+ return undefined;
203
+ }
204
+ try {
205
+ const claims = await this.manager.validate(token);
206
+ return claims.guest ? claims.sub : undefined;
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ }
213
+ //# sourceMappingURL=authController.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authController.js","sourceRoot":"","sources":["../../src/controllers/authController.ts"],"names":[],"mappings":"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","sourcesContent":["import { Router, type CookieOptions, type Request, type Response } from 'express';\nimport { randomBytes } from 'node:crypto';\nimport type { IAuthManager } from '../managers/types.js';\nimport type { IOidcProvider } from '../oidc/types.js';\nimport { type AuthConfig, OAUTH_COOKIE, SESSION_COOKIE } from '../config.js';\n\n/** The OAuth state/PKCE cookie only needs to outlive the redirect round-trip. */\nconst OAUTH_COOKIE_TTL_MS = 10 * 60 * 1000;\n\n/**\n * HTTP surface for client authentication (BFF cookie model).\n *\n * The session JWT rides in an httpOnly cookie ({@link SESSION_COOKIE}) — the\n * browser never sees it in JS — so endpoints derive identity from the cookie and\n * the UI learns who it is via `GET /auth/me`.\n *\n * Mounted under `/auth`:\n * POST /auth/guest -> mint a guest session; set the session cookie\n * POST /auth/refresh -> roll the session cookie forward\n * GET /auth/validate -> verify the cookie; X-Client-Id / X-Guest (gateway ForwardAuth)\n * GET /auth/me -> { clientId, guest, email? } for the UI\n * GET /auth/login -> begin Authorization Code + PKCE; redirect to the provider\n * GET /auth/callback -> finish login; promote the guest in place; set the session cookie\n * POST /auth/logout -> clear the session cookie (UI then mints a fresh guest)\n */\nexport class AuthController {\n public readonly router: Router;\n\n public constructor(\n private readonly manager: IAuthManager,\n private readonly oidc: IOidcProvider,\n private readonly config: AuthConfig,\n ) {\n this.router = Router();\n this.registerRoutes();\n }\n\n private registerRoutes(): void {\n this.router.post('/guest', this.createGuestSession);\n this.router.post('/refresh', this.refreshSession);\n this.router.get('/validate', this.validate);\n this.router.get('/me', this.me);\n this.router.get('/login', this.login);\n this.router.get('/callback', this.callback);\n this.router.post('/logout', this.logout);\n }\n\n private readonly createGuestSession = async (req: Request, res: Response): Promise<void> => {\n // Idempotent: if a valid session already exists (guest OR authenticated),\n // return it instead of minting a new guest — so this can never downgrade a\n // logged-in user or churn an existing guest. To abandon a session, use\n // POST /auth/logout (which clears the cookie); the next call then creates one.\n const existing = this.sessionToken(req);\n if (existing) {\n try {\n const claims = await this.manager.validate(existing);\n res.status(200).json({ clientId: claims.sub, guest: claims.guest });\n return;\n } catch {\n // Invalid/expired cookie -> fall through and mint a fresh guest.\n }\n }\n\n const session = await this.manager.createGuestSession();\n this.setSessionCookie(res, session.token, session.expiresAt);\n res.status(201).json({ clientId: session.clientId, guest: true });\n };\n\n private readonly refreshSession = async (req: Request, res: Response): Promise<void> => {\n const token = this.sessionToken(req);\n if (!token) {\n res.status(401).json({ error: 'no session' });\n return;\n }\n\n try {\n const session = await this.manager.refreshSession(token);\n this.setSessionCookie(res, session.token, session.expiresAt);\n const claims = await this.manager.validate(session.token);\n res.status(200).json({ clientId: claims.sub, guest: claims.guest });\n } catch {\n this.clearSessionCookie(res);\n res.status(401).json({ error: 'invalid or expired session' });\n }\n };\n\n private readonly validate = async (req: Request, res: Response): Promise<void> => {\n const token = this.sessionToken(req);\n if (!token) {\n res.status(401).json({ error: 'no session' });\n return;\n }\n\n try {\n const claims = await this.manager.validate(token);\n // The gateway (ForwardAuth) copies these onto the proxied request so\n // downstream services receive a trusted identity they didn't have to verify.\n res.setHeader('X-Client-Id', claims.sub);\n res.setHeader('X-Guest', String(claims.guest));\n res.status(200).json({ clientId: claims.sub, guest: claims.guest });\n } catch {\n res.status(401).json({ error: 'invalid or expired session' });\n }\n };\n\n private readonly me = async (req: Request, res: Response): Promise<void> => {\n const token = this.sessionToken(req);\n if (!token) {\n res.status(401).json({ error: 'no session' });\n return;\n }\n\n try {\n const info = await this.manager.describeSession(token);\n res.status(200).json(info);\n } catch {\n res.status(401).json({ error: 'invalid or expired session' });\n }\n };\n\n private readonly login = async (_req: Request, res: Response): Promise<void> => {\n // Generic, provider-agnostic CSRF state + PKCE verifier; the provider turns\n // the verifier into a code_challenge when building the authorize URL.\n const state = randomBytes(16).toString('base64url');\n const codeVerifier = randomBytes(32).toString('base64url');\n\n try {\n const url = await this.oidc.authorizeUrl(state, codeVerifier);\n res.cookie(OAUTH_COOKIE, JSON.stringify({ state, codeVerifier }), {\n ...this.baseCookieOptions(),\n // path '/' (not the mount path) so it survives wherever the auth\n // routes are mounted; it's short-lived, signed and single-use.\n maxAge: OAUTH_COOKIE_TTL_MS,\n signed: true,\n });\n res.redirect(302, url);\n } catch {\n res.status(503).json({ error: 'login not configured' });\n }\n };\n\n private readonly callback = async (req: Request, res: Response): Promise<void> => {\n const code = typeof req.query.code === 'string' ? req.query.code : undefined;\n const state = typeof req.query.state === 'string' ? req.query.state : undefined;\n const stash = this.readOauthCookie(req);\n\n // The OAuth cookie is single-use.\n res.clearCookie(OAUTH_COOKIE, { path: '/' });\n\n if (!code || !state || !stash || stash.state !== state) {\n res.status(400).json({ error: 'invalid oauth callback' });\n return;\n }\n\n try {\n // Pass ALL callback params (code, state, iss, …) through to the provider.\n const queryIndex = req.originalUrl.indexOf('?');\n const callbackParams = new URLSearchParams(\n queryIndex >= 0 ? req.originalUrl.slice(queryIndex + 1) : '',\n );\n const identity = await this.oidc.exchangeCode(callbackParams, stash.codeVerifier);\n // Promote the current guest in place if one is present on this device.\n const guestClientId = await this.guestClientId(req);\n const resolved = await this.manager.resolveLogin(identity, guestClientId);\n\n const session = await this.manager.createAuthenticatedSession(resolved.clientId);\n // Overwriting the session cookie also \"clears\" the guest session.\n this.setSessionCookie(res, session.token, session.expiresAt);\n res.redirect(302, this.config.uiRedirectUrl);\n } catch (e) {\n console.log(e);\n res.status(400).json({ error: 'login failed' });\n }\n };\n\n private readonly logout = async (_req: Request, res: Response): Promise<void> => {\n this.clearSessionCookie(res);\n res.status(204).end();\n };\n\n // --- cookie helpers ---\n\n private baseCookieOptions(): CookieOptions {\n return {\n httpOnly: true,\n sameSite: 'lax',\n secure: this.config.secureCookies,\n path: '/',\n };\n }\n\n private setSessionCookie(res: Response, token: string, expiresAt: number): void {\n res.cookie(SESSION_COOKIE, token, {\n ...this.baseCookieOptions(),\n maxAge: Math.max(0, expiresAt * 1000 - Date.now()),\n });\n }\n\n private clearSessionCookie(res: Response): void {\n res.clearCookie(SESSION_COOKIE, { path: '/' });\n }\n\n private sessionToken(req: Request): string | undefined {\n const value = req.cookies?.[SESSION_COOKIE];\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n }\n\n private readOauthCookie(req: Request): { state: string; codeVerifier: string } | undefined {\n const raw = req.signedCookies?.[OAUTH_COOKIE];\n if (typeof raw !== 'string') {\n return undefined;\n }\n try {\n const parsed = JSON.parse(raw) as { state?: unknown; codeVerifier?: unknown };\n if (typeof parsed.state === 'string' && typeof parsed.codeVerifier === 'string') {\n return { state: parsed.state, codeVerifier: parsed.codeVerifier };\n }\n } catch {\n // fall through\n }\n return undefined;\n }\n\n /** The current session's clientId iff it is a (still valid) guest — for promote-in-place. */\n private async guestClientId(req: Request): Promise<string | undefined> {\n const token = this.sessionToken(req);\n if (!token) {\n return undefined;\n }\n try {\n const claims = await this.manager.validate(token);\n return claims.guest ? claims.sub : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
@@ -0,0 +1,29 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import cookieParser from 'cookie-parser';
4
+ import { AuthController } from './controllers/authController.js';
5
+ import { AuthManager } from './managers/authManager.js';
6
+ /**
7
+ * Builds the configured Express application (CORS w/ credentials, JSON + cookie
8
+ * parsing, /health, and the auth routes) from its dependencies, WITHOUT binding
9
+ * a port.
10
+ *
11
+ * The entry point (app.ts) and the integration tests share this factory so they
12
+ * exercise the same wiring; only the injected collaborators differ (real
13
+ * Postgres + Google at runtime; in-memory repo + fake provider in tests).
14
+ */
15
+ export function createApp({ signer, clients, oidc, config }) {
16
+ const app = express();
17
+ // Credentialed CORS so the browser sends/receives the httpOnly session cookie.
18
+ app.use(cors({ origin: config.corsOrigin, credentials: true }));
19
+ app.use(express.json());
20
+ app.use(cookieParser(config.cookieSecret));
21
+ app.get('/health', (_req, res) => {
22
+ res.json({ status: 'ok' });
23
+ });
24
+ const manager = new AuthManager(signer, clients);
25
+ const controller = new AuthController(manager, oidc, config);
26
+ app.use('/api/v1/auth', controller.router);
27
+ return app;
28
+ }
29
+ //# sourceMappingURL=createApp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createApp.js","sourceRoot":"","sources":["../src/createApp.ts"],"names":[],"mappings":"AAAA,OAAO,OAAsD,MAAM,SAAS,CAAC;AAC7E,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,YAAY,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAcxD;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAmB;IACxE,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;IAEtB,+EAA+E;IAC/E,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,UAAU,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAChE,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IACxB,GAAG,CAAC,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC;IAE3C,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,IAAa,EAAE,GAAa,EAAE,EAAE;QAChD,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/B,CAAC,CAAC,CAAC;IAEH,MAAM,OAAO,GAAG,IAAI,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjD,MAAM,UAAU,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;IAC7D,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC;IAE3C,OAAO,GAAG,CAAC;AACf,CAAC","sourcesContent":["import express, { type Express, type Request, type Response } from 'express';\nimport cors from 'cors';\nimport cookieParser from 'cookie-parser';\nimport { AuthController } from './controllers/authController.js';\nimport { AuthManager } from './managers/authManager.js';\nimport type { IJwtSigner } from './signer/types.js';\nimport type { IClientRepository } from './repository/types.js';\nimport type { IOidcProvider } from './oidc/types.js';\nimport type { AuthConfig } from './config.js';\n\n/** Collaborators the app is built from. Injected so tests can swap in fakes. */\nexport interface AppDependencies {\n signer: IJwtSigner;\n clients: IClientRepository;\n oidc: IOidcProvider;\n config: AuthConfig;\n}\n\n/**\n * Builds the configured Express application (CORS w/ credentials, JSON + cookie\n * parsing, /health, and the auth routes) from its dependencies, WITHOUT binding\n * a port.\n *\n * The entry point (app.ts) and the integration tests share this factory so they\n * exercise the same wiring; only the injected collaborators differ (real\n * Postgres + Google at runtime; in-memory repo + fake provider in tests).\n */\nexport function createApp({ signer, clients, oidc, config }: AppDependencies): Express {\n const app = express();\n\n // Credentialed CORS so the browser sends/receives the httpOnly session cookie.\n app.use(cors({ origin: config.corsOrigin, credentials: true }));\n app.use(express.json());\n app.use(cookieParser(config.cookieSecret));\n\n app.get('/health', (_req: Request, res: Response) => {\n res.json({ status: 'ok' });\n });\n\n const manager = new AuthManager(signer, clients);\n const controller = new AuthController(manager, oidc, config);\n app.use('/api/v1/auth', controller.router);\n\n return app;\n}\n"]}
@@ -0,0 +1,96 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import pg from 'pg';
5
+ /**
6
+ * Forward-only migration runner, safe to run from several instances at once.
7
+ *
8
+ * Applies every `.sql` file in ./migrations (sorted by filename) that has not yet
9
+ * been recorded in `schema_migrations`, each in its own transaction. A cluster-wide
10
+ * Postgres advisory lock serializes migrators: the first caller applies the pending
11
+ * files; any others block on the lock, then acquire it, see everything already
12
+ * applied, and do nothing. The lock is session-scoped and held on a single dedicated
13
+ * connection for the whole run, so it also auto-releases if the process crashes.
14
+ *
15
+ * Connection comes from DATABASE_URL; the default targets the local dev Postgres in
16
+ * docker-compose.yml. Run standalone with `npm run migrate`, or it runs on service
17
+ * startup (see app.ts).
18
+ */
19
+ // Host port 5433 (see docker-compose.yml) so the dev container can coexist with a
20
+ // native Postgres that already owns 5432.
21
+ const DEFAULT_DATABASE_URL = 'postgres://crossly:crossly@127.0.0.1:5433/crossly_auth';
22
+ const MIGRATIONS_DIR = join(process.cwd(), 'migrations');
23
+ /** The Postgres connection string for the service (env override, else dev default). */
24
+ export function databaseUrl() {
25
+ return process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL;
26
+ }
27
+ // Constant key for the advisory lock that serializes migrations cluster-wide.
28
+ // Any fixed value works as long as it is unique to this concern.
29
+ const MIGRATION_LOCK_KEY = 4242420001;
30
+ export async function runMigrations(connectionString = databaseUrl()) {
31
+ const pool = new pg.Pool({ connectionString });
32
+ try {
33
+ await migrateWithPool(pool);
34
+ }
35
+ finally {
36
+ await pool.end();
37
+ }
38
+ }
39
+ async function migrateWithPool(pool) {
40
+ // Hold the advisory lock and run every migration on ONE connection so the
41
+ // session-scoped lock spans the entire run.
42
+ const client = await pool.connect();
43
+ try {
44
+ await client.query('SELECT pg_advisory_lock($1::bigint)', [MIGRATION_LOCK_KEY]);
45
+ await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
46
+ name TEXT PRIMARY KEY,
47
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
48
+ )`);
49
+ const files = readdirSync(MIGRATIONS_DIR)
50
+ .filter((file) => file.endsWith('.sql'))
51
+ .sort();
52
+ for (const file of files) {
53
+ const applied = await client.query('SELECT 1 FROM schema_migrations WHERE name = $1', [
54
+ file,
55
+ ]);
56
+ if ((applied.rowCount ?? 0) > 0) {
57
+ console.log(`skip ${file}`);
58
+ continue;
59
+ }
60
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
61
+ try {
62
+ await client.query('BEGIN');
63
+ await client.query(sql);
64
+ await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
65
+ await client.query('COMMIT');
66
+ console.log(`apply ${file}`);
67
+ }
68
+ catch (error) {
69
+ await client.query('ROLLBACK');
70
+ throw error;
71
+ }
72
+ }
73
+ console.log('migrations complete');
74
+ }
75
+ finally {
76
+ // Best-effort explicit unlock; releasing the connection also drops the
77
+ // session lock, so a failure here is harmless.
78
+ try {
79
+ await client.query('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);
80
+ }
81
+ catch {
82
+ // ignore
83
+ }
84
+ client.release();
85
+ }
86
+ }
87
+ // CLI entrypoint: only runs when invoked directly (`node dist/db/migrate.js`),
88
+ // not when imported by the app for startup migration.
89
+ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
90
+ if (invokedDirectly) {
91
+ runMigrations().catch((error) => {
92
+ console.error('migration failed:', error);
93
+ process.exit(1);
94
+ });
95
+ }
96
+ //# sourceMappingURL=migrate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/db/migrate.ts"],"names":[],"mappings":"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","sourcesContent":["import { readdirSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport pg from 'pg';\n\n/**\n * Forward-only migration runner, safe to run from several instances at once.\n *\n * Applies every `.sql` file in ./migrations (sorted by filename) that has not yet\n * been recorded in `schema_migrations`, each in its own transaction. A cluster-wide\n * Postgres advisory lock serializes migrators: the first caller applies the pending\n * files; any others block on the lock, then acquire it, see everything already\n * applied, and do nothing. The lock is session-scoped and held on a single dedicated\n * connection for the whole run, so it also auto-releases if the process crashes.\n *\n * Connection comes from DATABASE_URL; the default targets the local dev Postgres in\n * docker-compose.yml. Run standalone with `npm run migrate`, or it runs on service\n * startup (see app.ts).\n */\n// Host port 5433 (see docker-compose.yml) so the dev container can coexist with a\n// native Postgres that already owns 5432.\nconst DEFAULT_DATABASE_URL = 'postgres://crossly:crossly@127.0.0.1:5433/crossly_auth';\nconst MIGRATIONS_DIR = join(process.cwd(), 'migrations');\n\n/** The Postgres connection string for the service (env override, else dev default). */\nexport function databaseUrl(): string {\n return process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL;\n}\n\n// Constant key for the advisory lock that serializes migrations cluster-wide.\n// Any fixed value works as long as it is unique to this concern.\nconst MIGRATION_LOCK_KEY = 4242420001;\n\nexport async function runMigrations(connectionString: string = databaseUrl()): Promise<void> {\n const pool = new pg.Pool({ connectionString });\n try {\n await migrateWithPool(pool);\n } finally {\n await pool.end();\n }\n}\n\nasync function migrateWithPool(pool: pg.Pool): Promise<void> {\n // Hold the advisory lock and run every migration on ONE connection so the\n // session-scoped lock spans the entire run.\n const client = await pool.connect();\n try {\n await client.query('SELECT pg_advisory_lock($1::bigint)', [MIGRATION_LOCK_KEY]);\n\n await client.query(\n `CREATE TABLE IF NOT EXISTS schema_migrations (\n name TEXT PRIMARY KEY,\n applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )`,\n );\n\n const files = readdirSync(MIGRATIONS_DIR)\n .filter((file) => file.endsWith('.sql'))\n .sort();\n\n for (const file of files) {\n const applied = await client.query('SELECT 1 FROM schema_migrations WHERE name = $1', [\n file,\n ]);\n if ((applied.rowCount ?? 0) > 0) {\n console.log(`skip ${file}`);\n continue;\n }\n\n const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');\n try {\n await client.query('BEGIN');\n await client.query(sql);\n await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);\n await client.query('COMMIT');\n console.log(`apply ${file}`);\n } catch (error) {\n await client.query('ROLLBACK');\n throw error;\n }\n }\n\n console.log('migrations complete');\n } finally {\n // Best-effort explicit unlock; releasing the connection also drops the\n // session lock, so a failure here is harmless.\n try {\n await client.query('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);\n } catch {\n // ignore\n }\n client.release();\n }\n}\n\n// CLI entrypoint: only runs when invoked directly (`node dist/db/migrate.js`),\n// not when imported by the app for startup migration.\nconst invokedDirectly =\n process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;\n\nif (invokedDirectly) {\n runMigrations().catch((error) => {\n console.error('migration failed:', error);\n process.exit(1);\n });\n}\n"]}
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Sessions are valid for 1 year. Combined with refresh-on-use
4
+ * ({@link AuthManager.refreshSession}), an active anonymous user effectively
5
+ * never loses their session — it only lapses after a full year of inactivity.
6
+ */
7
+ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 365;
8
+ /**
9
+ * Default {@link IAuthManager} implementation.
10
+ *
11
+ * - {@link createGuestSession} mints a brand-new anonymous session (new clientId).
12
+ * - {@link refreshSession} re-issues a token for an existing session, preserving
13
+ * its identity but resetting the expiry (a sliding/rolling session).
14
+ * - {@link validate} verifies a token and returns its claims (used by the gateway).
15
+ * - {@link resolveLogin} maps a verified provider identity to a stable clientId,
16
+ * promoting the current guest in place on first login.
17
+ *
18
+ * Token signing/verification is delegated to the injected {@link IJwtSigner};
19
+ * account persistence to the injected {@link IClientRepository}.
20
+ */
21
+ export class AuthManager {
22
+ constructor(signer, clients) {
23
+ this.signer = signer;
24
+ this.clients = clients;
25
+ }
26
+ async createGuestSession() {
27
+ const clientId = randomUUID();
28
+ const { token, expiresAt } = await this.signer.sign(clientId, true, SESSION_TTL_SECONDS);
29
+ return { token, clientId, expiresAt };
30
+ }
31
+ async createAuthenticatedSession(clientId) {
32
+ const { token, expiresAt } = await this.signer.sign(clientId, false, SESSION_TTL_SECONDS);
33
+ return { token, clientId, expiresAt };
34
+ }
35
+ async describeSession(token) {
36
+ const claims = await this.signer.verify(token);
37
+ if (claims.guest) {
38
+ return { clientId: claims.sub, guest: true };
39
+ }
40
+ // Authenticated: surface the stored email (display only) if we have one.
41
+ const client = await this.clients.findById(claims.sub);
42
+ return { clientId: claims.sub, guest: false, email: client?.email };
43
+ }
44
+ async refreshSession(token) {
45
+ // Verify the current token; throws if it is invalid or expired.
46
+ const claims = await this.signer.verify(token);
47
+ // Re-issue for the SAME identity with a fresh expiry, so an active user's
48
+ // session keeps rolling forward.
49
+ const reissued = await this.signer.sign(claims.sub, claims.guest, SESSION_TTL_SECONDS);
50
+ return { token: reissued.token, clientId: claims.sub, expiresAt: reissued.expiresAt };
51
+ }
52
+ validate(token) {
53
+ // Verify signature + expiry; resolves with the claims or rejects.
54
+ return this.signer.verify(token);
55
+ }
56
+ async resolveLogin(identity, guestClientId) {
57
+ const existing = await this.clients.findByProvider(identity.provider, identity.subject);
58
+ if (existing) {
59
+ // Returning user on this provider — recognized on any device. The
60
+ // current guest id (if any) is discarded by the caller; this account's
61
+ // id wins.
62
+ await this.clients.touchLastLogin(existing.clientId);
63
+ return { clientId: existing.clientId, created: false, promoted: false };
64
+ }
65
+ // New external identity. Promote the current guest in place if one is
66
+ // present (the account adopts the guest's id, so the guest's data on this
67
+ // device carries over with no migration); otherwise mint a fresh id.
68
+ const promoted = guestClientId !== undefined;
69
+ const clientId = guestClientId ?? randomUUID();
70
+ await this.clients.create({
71
+ clientId,
72
+ provider: identity.provider,
73
+ providerSubject: identity.subject,
74
+ email: identity.email,
75
+ });
76
+ return { clientId, created: true, promoted };
77
+ }
78
+ }
79
+ //# sourceMappingURL=authManager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authManager.js","sourceRoot":"","sources":["../../src/managers/authManager.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto';\nimport type {\n AccessTokenClaims,\n GuestSessionResponse,\n MeResponse,\n} from '@textyly/crossly-client-auth-contracts';\nimport type { IAuthManager, ResolvedLogin } from './types.js';\nimport type { IJwtSigner } from '../signer/types.js';\nimport type { IClientRepository } from '../repository/types.js';\nimport type { OidcIdentity } from '../oidc/types.js';\n\n/**\n * Sessions are valid for 1 year. Combined with refresh-on-use\n * ({@link AuthManager.refreshSession}), an active anonymous user effectively\n * never loses their session — it only lapses after a full year of inactivity.\n */\nconst SESSION_TTL_SECONDS: number = 60 * 60 * 24 * 365;\n\n/**\n * Default {@link IAuthManager} implementation.\n *\n * - {@link createGuestSession} mints a brand-new anonymous session (new clientId).\n * - {@link refreshSession} re-issues a token for an existing session, preserving\n * its identity but resetting the expiry (a sliding/rolling session).\n * - {@link validate} verifies a token and returns its claims (used by the gateway).\n * - {@link resolveLogin} maps a verified provider identity to a stable clientId,\n * promoting the current guest in place on first login.\n *\n * Token signing/verification is delegated to the injected {@link IJwtSigner};\n * account persistence to the injected {@link IClientRepository}.\n */\nexport class AuthManager implements IAuthManager {\n public constructor(\n private readonly signer: IJwtSigner,\n private readonly clients: IClientRepository,\n ) {}\n\n public async createGuestSession(): Promise<GuestSessionResponse> {\n const clientId = randomUUID();\n const { token, expiresAt } = await this.signer.sign(clientId, true, SESSION_TTL_SECONDS);\n\n return { token, clientId, expiresAt };\n }\n\n public async createAuthenticatedSession(clientId: string): Promise<GuestSessionResponse> {\n const { token, expiresAt } = await this.signer.sign(clientId, false, SESSION_TTL_SECONDS);\n return { token, clientId, expiresAt };\n }\n\n public async describeSession(token: string): Promise<MeResponse> {\n const claims = await this.signer.verify(token);\n if (claims.guest) {\n return { clientId: claims.sub, guest: true };\n }\n\n // Authenticated: surface the stored email (display only) if we have one.\n const client = await this.clients.findById(claims.sub);\n return { clientId: claims.sub, guest: false, email: client?.email };\n }\n\n public async refreshSession(token: string): Promise<GuestSessionResponse> {\n // Verify the current token; throws if it is invalid or expired.\n const claims = await this.signer.verify(token);\n\n // Re-issue for the SAME identity with a fresh expiry, so an active user's\n // session keeps rolling forward.\n const reissued = await this.signer.sign(claims.sub, claims.guest, SESSION_TTL_SECONDS);\n\n return { token: reissued.token, clientId: claims.sub, expiresAt: reissued.expiresAt };\n }\n\n public validate(token: string): Promise<AccessTokenClaims> {\n // Verify signature + expiry; resolves with the claims or rejects.\n return this.signer.verify(token);\n }\n\n public async resolveLogin(\n identity: OidcIdentity,\n guestClientId?: string,\n ): Promise<ResolvedLogin> {\n const existing = await this.clients.findByProvider(identity.provider, identity.subject);\n if (existing) {\n // Returning user on this provider — recognized on any device. The\n // current guest id (if any) is discarded by the caller; this account's\n // id wins.\n await this.clients.touchLastLogin(existing.clientId);\n return { clientId: existing.clientId, created: false, promoted: false };\n }\n\n // New external identity. Promote the current guest in place if one is\n // present (the account adopts the guest's id, so the guest's data on this\n // device carries over with no migration); otherwise mint a fresh id.\n const promoted = guestClientId !== undefined;\n const clientId = guestClientId ?? randomUUID();\n\n await this.clients.create({\n clientId,\n provider: identity.provider,\n providerSubject: identity.subject,\n email: identity.email,\n });\n\n return { clientId, created: true, promoted };\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/managers/types.ts"],"names":[],"mappings":"","sourcesContent":["import type {\n AccessTokenClaims,\n GuestSessionResponse,\n MeResponse,\n} from '@textyly/crossly-client-auth-contracts';\nimport type { OidcIdentity } from '../oidc/types.js';\n\n/**\n * Outcome of resolving a verified provider identity to a stable client.\n */\nexport interface ResolvedLogin {\n /** The stable client id (== token `sub`) this login resolves to. */\n clientId: string;\n /** True if a new `clients` row was written (a brand-new account or a promotion). */\n created: boolean;\n /** True if an existing guest was promoted in place (its id became the account). */\n promoted: boolean;\n}\n\n/**\n * Business operations for client authentication. Sits between the controllers\n * (HTTP) and the signer (token issuance/verification) + client repository.\n */\nexport interface IAuthManager {\n /** Create a fresh anonymous guest session (new clientId + signed token). */\n createGuestSession(): Promise<GuestSessionResponse>;\n\n /** Mint an authenticated (guest:false) session token for an existing clientId. */\n createAuthenticatedSession(clientId: string): Promise<GuestSessionResponse>;\n\n /**\n * Verify a token and describe the session for the UI: `{ clientId, guest, email? }`.\n * Email is looked up for authenticated users; guests have none. Throws if the\n * token is invalid or expired.\n */\n describeSession(token: string): Promise<MeResponse>;\n\n /**\n * Re-issue a token for the session identified by `token`, preserving its\n * identity (clientId) but resetting the expiry. Throws if the token is\n * invalid or expired.\n */\n refreshSession(token: string): Promise<GuestSessionResponse>;\n\n /**\n * Verify a token and return its claims. Throws if the token is invalid or\n * expired. Used by the gateway (ForwardAuth) to turn a token into a trusted\n * clientId.\n */\n validate(token: string): Promise<AccessTokenClaims>;\n\n /**\n * Resolve a verified provider identity to a stable clientId:\n * - existing `(provider, subject)` → that client (returning user, any device);\n * - else, a guest is present → **promote in place** (reuse `guestClientId`);\n * - else → create a brand-new client.\n */\n resolveLogin(identity: OidcIdentity, guestClientId?: string): Promise<ResolvedLogin>;\n}\n"]}
@@ -0,0 +1,54 @@
1
+ import * as oidc from 'openid-client';
2
+ const GOOGLE_ISSUER = new URL('https://accounts.google.com');
3
+ const SCOPE = 'openid email profile';
4
+ /**
5
+ * {@link IOidcProvider} backed by Google via `openid-client` (Authorization Code
6
+ * + PKCE). Created with {@link GoogleProvider.create}, which performs OIDC
7
+ * discovery once against Google's well-known configuration.
8
+ *
9
+ * State/CSRF is handled by the controller (it owns the cookie), so the code
10
+ * exchange here skips openid-client's own state check.
11
+ */
12
+ export class GoogleProvider {
13
+ constructor(config, redirectUri) {
14
+ this.config = config;
15
+ this.redirectUri = redirectUri;
16
+ }
17
+ static async create(options) {
18
+ const config = await oidc.discovery(GOOGLE_ISSUER, options.clientId, options.clientSecret);
19
+ return new GoogleProvider(config, options.redirectUri);
20
+ }
21
+ async authorizeUrl(state, codeVerifier) {
22
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
23
+ const url = oidc.buildAuthorizationUrl(this.config, {
24
+ redirect_uri: this.redirectUri,
25
+ scope: SCOPE,
26
+ code_challenge: codeChallenge,
27
+ code_challenge_method: 'S256',
28
+ state,
29
+ });
30
+ return url.href;
31
+ }
32
+ async exchangeCode(callbackParams, codeVerifier) {
33
+ // Rebuild the exact callback URL openid-client expects: our redirect URI
34
+ // plus ALL of the provider's returned params (code, state, iss, …) so its
35
+ // response validation (incl. the RFC 9207 issuer check) passes.
36
+ const currentUrl = new URL(this.redirectUri);
37
+ currentUrl.search = callbackParams.toString();
38
+ const tokens = await oidc.authorizationCodeGrant(this.config, currentUrl, {
39
+ pkceCodeVerifier: codeVerifier,
40
+ // The controller already validated `state` against its cookie.
41
+ expectedState: oidc.skipStateCheck,
42
+ });
43
+ const claims = tokens.claims();
44
+ if (!claims?.sub) {
45
+ throw new Error('google login did not return a subject');
46
+ }
47
+ return {
48
+ provider: 'google',
49
+ subject: claims.sub,
50
+ email: typeof claims.email === 'string' ? claims.email : undefined,
51
+ };
52
+ }
53
+ }
54
+ //# sourceMappingURL=googleProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"googleProvider.js","sourceRoot":"","sources":["../../src/oidc/googleProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAUtC,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,6BAA6B,CAAC,CAAC;AAC7D,MAAM,KAAK,GAAG,sBAAsB,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,OAAO,cAAc;IACvB,YACqB,MAA0B,EAC1B,WAAmB;QADnB,WAAM,GAAN,MAAM,CAAoB;QAC1B,gBAAW,GAAX,WAAW,CAAQ;IACrC,CAAC;IAEG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,OAA8B;QACrD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,aAAa,EAAE,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;QAC3F,OAAO,IAAI,cAAc,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC3D,CAAC;IAEM,KAAK,CAAC,YAAY,CAAC,KAAa,EAAE,YAAoB;QACzD,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,0BAA0B,CAAC,YAAY,CAAC,CAAC;QAC1E,MAAM,GAAG,GAAG,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,MAAM,EAAE;YAChD,YAAY,EAAE,IAAI,CAAC,WAAW;YAC9B,KAAK,EAAE,KAAK;YACZ,cAAc,EAAE,aAAa;YAC7B,qBAAqB,EAAE,MAAM;YAC7B,KAAK;SACR,CAAC,CAAC;QACH,OAAO,GAAG,CAAC,IAAI,CAAC;IACpB,CAAC;IAEM,KAAK,CAAC,YAAY,CACrB,cAA+B,EAC/B,YAAoB;QAEpB,yEAAyE;QACzE,0EAA0E;QAC1E,gEAAgE;QAChE,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC7C,UAAU,CAAC,MAAM,GAAG,cAAc,CAAC,QAAQ,EAAE,CAAC;QAE9C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE;YACtE,gBAAgB,EAAE,YAAY;YAC9B,+DAA+D;YAC/D,aAAa,EAAE,IAAI,CAAC,cAAc;SACrC,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QAC/B,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;QAC7D,CAAC;QAED,OAAO;YACH,QAAQ,EAAE,QAAQ;YAClB,OAAO,EAAE,MAAM,CAAC,GAAG;YACnB,KAAK,EAAE,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;SACrE,CAAC;IACN,CAAC;CACJ","sourcesContent":["import * as oidc from 'openid-client';\nimport type { IOidcProvider, OidcIdentity } from './types.js';\n\nexport interface GoogleProviderOptions {\n clientId: string;\n clientSecret: string;\n /** Must exactly match an Authorized redirect URI on the Google OAuth client. */\n redirectUri: string;\n}\n\nconst GOOGLE_ISSUER = new URL('https://accounts.google.com');\nconst SCOPE = 'openid email profile';\n\n/**\n * {@link IOidcProvider} backed by Google via `openid-client` (Authorization Code\n * + PKCE). Created with {@link GoogleProvider.create}, which performs OIDC\n * discovery once against Google's well-known configuration.\n *\n * State/CSRF is handled by the controller (it owns the cookie), so the code\n * exchange here skips openid-client's own state check.\n */\nexport class GoogleProvider implements IOidcProvider {\n private constructor(\n private readonly config: oidc.Configuration,\n private readonly redirectUri: string,\n ) {}\n\n public static async create(options: GoogleProviderOptions): Promise<GoogleProvider> {\n const config = await oidc.discovery(GOOGLE_ISSUER, options.clientId, options.clientSecret);\n return new GoogleProvider(config, options.redirectUri);\n }\n\n public async authorizeUrl(state: string, codeVerifier: string): Promise<string> {\n const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);\n const url = oidc.buildAuthorizationUrl(this.config, {\n redirect_uri: this.redirectUri,\n scope: SCOPE,\n code_challenge: codeChallenge,\n code_challenge_method: 'S256',\n state,\n });\n return url.href;\n }\n\n public async exchangeCode(\n callbackParams: URLSearchParams,\n codeVerifier: string,\n ): Promise<OidcIdentity> {\n // Rebuild the exact callback URL openid-client expects: our redirect URI\n // plus ALL of the provider's returned params (code, state, iss, …) so its\n // response validation (incl. the RFC 9207 issuer check) passes.\n const currentUrl = new URL(this.redirectUri);\n currentUrl.search = callbackParams.toString();\n\n const tokens = await oidc.authorizationCodeGrant(this.config, currentUrl, {\n pkceCodeVerifier: codeVerifier,\n // The controller already validated `state` against its cookie.\n expectedState: oidc.skipStateCheck,\n });\n\n const claims = tokens.claims();\n if (!claims?.sub) {\n throw new Error('google login did not return a subject');\n }\n\n return {\n provider: 'google',\n subject: claims.sub,\n email: typeof claims.email === 'string' ? claims.email : undefined,\n };\n }\n}\n"]}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Placeholder {@link IOidcProvider} used when no real provider is configured
3
+ * (e.g. Google client id/secret absent in local dev). Guest sessions keep
4
+ * working; any attempt to log in fails loudly so the controller can return 503.
5
+ */
6
+ export class NotConfiguredOidcProvider {
7
+ authorizeUrl() {
8
+ throw new Error('OIDC login is not configured');
9
+ }
10
+ exchangeCode() {
11
+ throw new Error('OIDC login is not configured');
12
+ }
13
+ }
14
+ //# sourceMappingURL=notConfiguredOidcProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notConfiguredOidcProvider.js","sourceRoot":"","sources":["../../src/oidc/notConfiguredOidcProvider.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,OAAO,yBAAyB;IAC3B,YAAY;QACf,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IACpD,CAAC;IAEM,YAAY;QACf,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IACpD,CAAC;CACJ","sourcesContent":["import type { IOidcProvider, OidcIdentity } from './types.js';\n\n/**\n * Placeholder {@link IOidcProvider} used when no real provider is configured\n * (e.g. Google client id/secret absent in local dev). Guest sessions keep\n * working; any attempt to log in fails loudly so the controller can return 503.\n */\nexport class NotConfiguredOidcProvider implements IOidcProvider {\n public authorizeUrl(): Promise<string> {\n throw new Error('OIDC login is not configured');\n }\n\n public exchangeCode(): Promise<OidcIdentity> {\n throw new Error('OIDC login is not configured');\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/oidc/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * The identity an OIDC provider asserts after a successful login.\n *\n * `(provider, subject)` is the client identity key — `subject` is the provider's\n * stable user id (their `sub`), which is the same across all of that user's\n * devices. `email` is provider-reported and kept for display/contact only; it is\n * NEVER used to match or link clients.\n */\nexport interface OidcIdentity {\n /** Stable provider name, e.g. 'google'. Part of the client identity key. */\n provider: string;\n /** The provider's stable subject id for this user (their `sub`). */\n subject: string;\n /** Provider-reported email — display/contact only, never a matching key. */\n email?: string;\n}\n\n/**\n * Boundary to an OIDC identity provider (Google now; GitHub / Facebook / a broker\n * like Auth0 later). Implementations own the provider-specific OAuth/OIDC\n * mechanics; the rest of the service depends only on this interface, so adding a\n * provider is a single new class with no changes elsewhere.\n */\nexport interface IOidcProvider {\n /**\n * Build the provider's authorization URL to redirect the user to\n * (Authorization Code flow + PKCE). `state` and `codeVerifier` are generated\n * by the caller and stashed for the callback.\n */\n authorizeUrl(state: string, codeVerifier: string): Promise<string>;\n\n /**\n * Exchange the authorization callback for the verified {@link OidcIdentity}.\n *\n * `callbackParams` are ALL query parameters the provider returned on the\n * redirect (code, state, iss, …) — passed whole so the OIDC library can\n * validate them (e.g. the RFC 9207 `iss`). `codeVerifier` is the stashed PKCE\n * verifier.\n */\n exchangeCode(callbackParams: URLSearchParams, codeVerifier: string): Promise<OidcIdentity>;\n}\n"]}