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,60 @@
1
+ /**
2
+ * In-memory implementation of {@link IClientRepository}.
3
+ *
4
+ * State lives in two Maps (by clientId, and a (provider, subject) -> clientId
5
+ * index) and is lost on restart. Used by the unit tests and for running locally
6
+ * without a database; {@link PgClientRepository} is the durable implementation
7
+ * wired into the service at runtime. Records are cloned on the way in and out so
8
+ * callers cannot mutate stored state by reference.
9
+ */
10
+ export class InMemoryClientRepository {
11
+ constructor() {
12
+ this.byClientId = new Map();
13
+ this.byIdentity = new Map();
14
+ }
15
+ async findByProvider(provider, providerSubject) {
16
+ const clientId = this.byIdentity.get(this.identityKey(provider, providerSubject));
17
+ if (!clientId) {
18
+ return undefined;
19
+ }
20
+ const found = this.byClientId.get(clientId);
21
+ return found ? { ...found } : undefined;
22
+ }
23
+ async findById(clientId) {
24
+ const found = this.byClientId.get(clientId);
25
+ return found ? { ...found } : undefined;
26
+ }
27
+ async create(client) {
28
+ const idKey = this.identityKey(client.provider, client.providerSubject);
29
+ if (this.byClientId.has(client.clientId)) {
30
+ throw new Error(`client already exists: ${client.clientId}`);
31
+ }
32
+ if (this.byIdentity.has(idKey)) {
33
+ throw new Error(`identity already mapped: ${client.provider}/${client.providerSubject}`);
34
+ }
35
+ const record = {
36
+ clientId: client.clientId,
37
+ provider: client.provider,
38
+ providerSubject: client.providerSubject,
39
+ email: client.email,
40
+ createdAt: new Date(),
41
+ lastLoginAt: undefined,
42
+ };
43
+ this.byClientId.set(record.clientId, record);
44
+ this.byIdentity.set(idKey, record.clientId);
45
+ return { ...record };
46
+ }
47
+ async touchLastLogin(clientId) {
48
+ const existing = this.byClientId.get(clientId);
49
+ if (!existing) {
50
+ return;
51
+ }
52
+ existing.lastLoginAt = new Date();
53
+ }
54
+ // A unique key for a (provider, subject) pair. The pair is JSON-encoded so no
55
+ // combination of provider/subject characters can produce a colliding key.
56
+ identityKey(provider, providerSubject) {
57
+ return JSON.stringify([provider, providerSubject]);
58
+ }
59
+ }
60
+ //# sourceMappingURL=inMemoryClientRepository.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inMemoryClientRepository.js","sourceRoot":"","sources":["../../src/repository/inMemoryClientRepository.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,OAAO,wBAAwB;IAArC;QACqB,eAAU,GAA8B,IAAI,GAAG,EAAE,CAAC;QAClD,eAAU,GAAwB,IAAI,GAAG,EAAE,CAAC;IA0DjE,CAAC;IAxDU,KAAK,CAAC,cAAc,CACvB,QAAgB,EAChB,eAAuB;QAEvB,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,eAAe,CAAC,CAAC,CAAC;QAClF,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,OAAO,SAAS,CAAC;QACrB,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5C,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5C,CAAC;IAEM,KAAK,CAAC,QAAQ,CAAC,QAAgB;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5C,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5C,CAAC;IAEM,KAAK,CAAC,MAAM,CAAC,MAAiB;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,eAAe,CAAC,CAAC;QAExE,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YACvC,MAAM,IAAI,KAAK,CAAC,0BAA0B,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QACjE,CAAC;QACD,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CAAC,4BAA4B,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,eAAe,EAAE,CAAC,CAAC;QAC7F,CAAC;QAED,MAAM,MAAM,GAAiB;YACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,eAAe,EAAE,MAAM,CAAC,eAAe;YACvC,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,SAAS,EAAE,IAAI,IAAI,EAAE;YACrB,WAAW,EAAE,SAAS;SACzB,CAAC;QAEF,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC7C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC5C,OAAO,EAAE,GAAG,MAAM,EAAE,CAAC;IACzB,CAAC;IAEM,KAAK,CAAC,cAAc,CAAC,QAAgB;QACxC,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC/C,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,OAAO;QACX,CAAC;QAED,QAAQ,CAAC,WAAW,GAAG,IAAI,IAAI,EAAE,CAAC;IACtC,CAAC;IAED,8EAA8E;IAC9E,0EAA0E;IAClE,WAAW,CAAC,QAAgB,EAAE,eAAuB;QACzD,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,eAAe,CAAC,CAAC,CAAC;IACvD,CAAC;CACJ","sourcesContent":["import type { ClientRecord, IClientRepository, NewClient } from './types.js';\n\n/**\n * In-memory implementation of {@link IClientRepository}.\n *\n * State lives in two Maps (by clientId, and a (provider, subject) -> clientId\n * index) and is lost on restart. Used by the unit tests and for running locally\n * without a database; {@link PgClientRepository} is the durable implementation\n * wired into the service at runtime. Records are cloned on the way in and out so\n * callers cannot mutate stored state by reference.\n */\nexport class InMemoryClientRepository implements IClientRepository {\n private readonly byClientId: Map<string, ClientRecord> = new Map();\n private readonly byIdentity: Map<string, string> = new Map();\n\n public async findByProvider(\n provider: string,\n providerSubject: string,\n ): Promise<ClientRecord | undefined> {\n const clientId = this.byIdentity.get(this.identityKey(provider, providerSubject));\n if (!clientId) {\n return undefined;\n }\n\n const found = this.byClientId.get(clientId);\n return found ? { ...found } : undefined;\n }\n\n public async findById(clientId: string): Promise<ClientRecord | undefined> {\n const found = this.byClientId.get(clientId);\n return found ? { ...found } : undefined;\n }\n\n public async create(client: NewClient): Promise<ClientRecord> {\n const idKey = this.identityKey(client.provider, client.providerSubject);\n\n if (this.byClientId.has(client.clientId)) {\n throw new Error(`client already exists: ${client.clientId}`);\n }\n if (this.byIdentity.has(idKey)) {\n throw new Error(`identity already mapped: ${client.provider}/${client.providerSubject}`);\n }\n\n const record: ClientRecord = {\n clientId: client.clientId,\n provider: client.provider,\n providerSubject: client.providerSubject,\n email: client.email,\n createdAt: new Date(),\n lastLoginAt: undefined,\n };\n\n this.byClientId.set(record.clientId, record);\n this.byIdentity.set(idKey, record.clientId);\n return { ...record };\n }\n\n public async touchLastLogin(clientId: string): Promise<void> {\n const existing = this.byClientId.get(clientId);\n if (!existing) {\n return;\n }\n\n existing.lastLoginAt = new Date();\n }\n\n // A unique key for a (provider, subject) pair. The pair is JSON-encoded so no\n // combination of provider/subject characters can produce a colliding key.\n private identityKey(provider: string, providerSubject: string): string {\n return JSON.stringify([provider, providerSubject]);\n }\n}\n"]}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PostgreSQL-backed implementation of {@link IClientRepository}.
3
+ *
4
+ * A connected {@link pg.Pool} is injected; its lifecycle (and the schema, via the
5
+ * migration runner) is owned by the composition root. The UNIQUE (provider,
6
+ * provider_subject) constraint enforces one client per external identity at the
7
+ * database level, so a concurrent duplicate `create` rejects rather than racing.
8
+ */
9
+ export class PgClientRepository {
10
+ constructor(pool) {
11
+ this.pool = pool;
12
+ }
13
+ async findByProvider(provider, providerSubject) {
14
+ const result = await this.pool.query('SELECT * FROM clients WHERE provider = $1 AND provider_subject = $2', [provider, providerSubject]);
15
+ const row = result.rows[0];
16
+ return row ? this.toDomain(row) : undefined;
17
+ }
18
+ async findById(clientId) {
19
+ const result = await this.pool.query('SELECT * FROM clients WHERE client_id = $1', [clientId]);
20
+ const row = result.rows[0];
21
+ return row ? this.toDomain(row) : undefined;
22
+ }
23
+ async create(client) {
24
+ const result = await this.pool.query(`INSERT INTO clients (client_id, provider, provider_subject, email)
25
+ VALUES ($1, $2, $3, $4)
26
+ RETURNING *`, [client.clientId, client.provider, client.providerSubject, client.email ?? null]);
27
+ return this.toDomain(result.rows[0]);
28
+ }
29
+ async touchLastLogin(clientId) {
30
+ await this.pool.query('UPDATE clients SET last_login_at = now() WHERE client_id = $1', [
31
+ clientId,
32
+ ]);
33
+ }
34
+ toDomain(row) {
35
+ return {
36
+ clientId: row.client_id,
37
+ provider: row.provider,
38
+ providerSubject: row.provider_subject,
39
+ email: row.email ?? undefined,
40
+ createdAt: row.created_at,
41
+ lastLoginAt: row.last_login_at ?? undefined,
42
+ };
43
+ }
44
+ }
45
+ //# sourceMappingURL=pgClientRepository.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pgClientRepository.js","sourceRoot":"","sources":["../../src/repository/pgClientRepository.ts"],"names":[],"mappings":"AAaA;;;;;;;GAOG;AACH,MAAM,OAAO,kBAAkB;IAC3B,YAAoC,IAAa;QAAb,SAAI,GAAJ,IAAI,CAAS;IAAG,CAAC;IAE9C,KAAK,CAAC,cAAc,CACvB,QAAgB,EAChB,eAAuB;QAEvB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAChC,qEAAqE,EACrE,CAAC,QAAQ,EAAE,eAAe,CAAC,CAC9B,CAAC;QAEF,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3B,OAAO,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAChD,CAAC;IAEM,KAAK,CAAC,QAAQ,CAAC,QAAgB;QAClC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAChC,4CAA4C,EAC5C,CAAC,QAAQ,CAAC,CACb,CAAC;QAEF,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3B,OAAO,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAChD,CAAC;IAEM,KAAK,CAAC,MAAM,CAAC,MAAiB;QACjC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAChC;;yBAEa,EACb,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,KAAK,IAAI,IAAI,CAAC,CACnF,CAAC;QAEF,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACzC,CAAC;IAEM,KAAK,CAAC,cAAc,CAAC,QAAgB;QACxC,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,+DAA+D,EAAE;YACnF,QAAQ;SACX,CAAC,CAAC;IACP,CAAC;IAEO,QAAQ,CAAC,GAAc;QAC3B,OAAO;YACH,QAAQ,EAAE,GAAG,CAAC,SAAS;YACvB,QAAQ,EAAE,GAAG,CAAC,QAAQ;YACtB,eAAe,EAAE,GAAG,CAAC,gBAAgB;YACrC,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,SAAS;YAC7B,SAAS,EAAE,GAAG,CAAC,UAAU;YACzB,WAAW,EAAE,GAAG,CAAC,aAAa,IAAI,SAAS;SAC9C,CAAC;IACN,CAAC;CACJ","sourcesContent":["import pg from 'pg';\nimport type { ClientRecord, IClientRepository, NewClient } from './types.js';\n\n/** Raw row shape returned from the `clients` table. */\ninterface ClientRow {\n client_id: string;\n provider: string;\n provider_subject: string;\n email: string | null;\n created_at: Date;\n last_login_at: Date | null;\n}\n\n/**\n * PostgreSQL-backed implementation of {@link IClientRepository}.\n *\n * A connected {@link pg.Pool} is injected; its lifecycle (and the schema, via the\n * migration runner) is owned by the composition root. The UNIQUE (provider,\n * provider_subject) constraint enforces one client per external identity at the\n * database level, so a concurrent duplicate `create` rejects rather than racing.\n */\nexport class PgClientRepository implements IClientRepository {\n public constructor(private readonly pool: pg.Pool) {}\n\n public async findByProvider(\n provider: string,\n providerSubject: string,\n ): Promise<ClientRecord | undefined> {\n const result = await this.pool.query<ClientRow>(\n 'SELECT * FROM clients WHERE provider = $1 AND provider_subject = $2',\n [provider, providerSubject],\n );\n\n const row = result.rows[0];\n return row ? this.toDomain(row) : undefined;\n }\n\n public async findById(clientId: string): Promise<ClientRecord | undefined> {\n const result = await this.pool.query<ClientRow>(\n 'SELECT * FROM clients WHERE client_id = $1',\n [clientId],\n );\n\n const row = result.rows[0];\n return row ? this.toDomain(row) : undefined;\n }\n\n public async create(client: NewClient): Promise<ClientRecord> {\n const result = await this.pool.query<ClientRow>(\n `INSERT INTO clients (client_id, provider, provider_subject, email)\n VALUES ($1, $2, $3, $4)\n RETURNING *`,\n [client.clientId, client.provider, client.providerSubject, client.email ?? null],\n );\n\n return this.toDomain(result.rows[0]);\n }\n\n public async touchLastLogin(clientId: string): Promise<void> {\n await this.pool.query('UPDATE clients SET last_login_at = now() WHERE client_id = $1', [\n clientId,\n ]);\n }\n\n private toDomain(row: ClientRow): ClientRecord {\n return {\n clientId: row.client_id,\n provider: row.provider,\n providerSubject: row.provider_subject,\n email: row.email ?? undefined,\n createdAt: row.created_at,\n lastLoginAt: row.last_login_at ?? undefined,\n };\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/repository/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * A persisted client (account).\n *\n * `clientId` equals the token `sub` and is immutable for the life of the client\n * (it is reused from the guest's id on promote-in-place). Identity is the pair\n * `(provider, providerSubject)` — the provider's stable subject is matched\n * directly; email is stored for display/contact only and is NEVER a matching key.\n *\n * Guests are NOT stored here — only clients that have logged in at least once.\n */\nexport interface ClientRecord {\n clientId: string;\n provider: string;\n providerSubject: string;\n email?: string;\n createdAt: Date;\n lastLoginAt?: Date;\n}\n\n/** Fields required to insert a new client. */\nexport interface NewClient {\n clientId: string;\n provider: string;\n providerSubject: string;\n email?: string;\n}\n\n/**\n * Persistence boundary for authenticated clients (accounts).\n *\n * Implementations may be Postgres ({@link PgClientRepository}) or in-memory\n * ({@link InMemoryClientRepository}); the rest of the service depends only on\n * this interface.\n */\nexport interface IClientRepository {\n /** Find a client by its external identity. Returns undefined if none exists. */\n findByProvider(provider: string, providerSubject: string): Promise<ClientRecord | undefined>;\n\n /** Find a client by its immutable clientId. Returns undefined if none exists. */\n findById(clientId: string): Promise<ClientRecord | undefined>;\n\n /**\n * Insert a new client. Rejects if `clientId` already exists or if\n * `(provider, providerSubject)` is already mapped to another client.\n */\n create(client: NewClient): Promise<ClientRecord>;\n\n /** Set last_login_at to now for the given client. No-op if the client is unknown. */\n touchLastLogin(clientId: string): Promise<void>;\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import { SignJWT, jwtVerify } from 'jose';
2
+ /**
3
+ * HS256 JWT signer backed by `jose`.
4
+ *
5
+ * "Extremely simple" by design: a single shared secret both signs and verifies
6
+ * tokens. When real identity providers are introduced, swap this for an
7
+ * RS256/JWKS implementation of {@link IJwtSigner} — nothing else in the service
8
+ * (or in consumers) changes, because the token shape stays the same.
9
+ */
10
+ export class JwtSigner {
11
+ constructor(secret) {
12
+ this.key = new TextEncoder().encode(secret);
13
+ }
14
+ async sign(sub, guest, ttlSeconds) {
15
+ const issuedAt = Math.floor(Date.now() / 1000);
16
+ const expiresAt = issuedAt + ttlSeconds;
17
+ const token = await new SignJWT({ guest })
18
+ .setProtectedHeader({ alg: JwtSigner.algorithm })
19
+ .setSubject(sub)
20
+ .setIssuedAt(issuedAt)
21
+ .setExpirationTime(expiresAt)
22
+ .sign(this.key);
23
+ return { token, expiresAt };
24
+ }
25
+ async verify(token) {
26
+ const { payload } = await jwtVerify(token, this.key);
27
+ return {
28
+ sub: payload.sub,
29
+ guest: payload.guest,
30
+ iat: payload.iat,
31
+ exp: payload.exp,
32
+ };
33
+ }
34
+ }
35
+ JwtSigner.algorithm = 'HS256';
36
+ //# sourceMappingURL=jwtSigner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwtSigner.js","sourceRoot":"","sources":["../../src/signer/jwtSigner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,MAAM,CAAC;AAI1C;;;;;;;GAOG;AACH,MAAM,OAAO,SAAS;IAIlB,YAAmB,MAAc;QAC7B,IAAI,CAAC,GAAG,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAChD,CAAC;IAEM,KAAK,CAAC,IAAI,CACb,GAAW,EACX,KAAc,EACd,UAAkB;QAElB,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QAC/C,MAAM,SAAS,GAAG,QAAQ,GAAG,UAAU,CAAC;QAExC,MAAM,KAAK,GAAG,MAAM,IAAI,OAAO,CAAC,EAAE,KAAK,EAAE,CAAC;aACrC,kBAAkB,CAAC,EAAE,GAAG,EAAE,SAAS,CAAC,SAAS,EAAE,CAAC;aAChD,UAAU,CAAC,GAAG,CAAC;aACf,WAAW,CAAC,QAAQ,CAAC;aACrB,iBAAiB,CAAC,SAAS,CAAC;aAC5B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEpB,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAChC,CAAC;IAEM,KAAK,CAAC,MAAM,CAAC,KAAa;QAC7B,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;QAErD,OAAO;YACH,GAAG,EAAE,OAAO,CAAC,GAAa;YAC1B,KAAK,EAAE,OAAO,CAAC,KAAgB;YAC/B,GAAG,EAAE,OAAO,CAAC,GAAa;YAC1B,GAAG,EAAE,OAAO,CAAC,GAAa;SAC7B,CAAC;IACN,CAAC;;AAlCuB,mBAAS,GAAW,OAAO,CAAC","sourcesContent":["import { SignJWT, jwtVerify } from 'jose';\nimport type { AccessTokenClaims } from '@textyly/crossly-client-auth-contracts';\nimport type { IJwtSigner } from './types.js';\n\n/**\n * HS256 JWT signer backed by `jose`.\n *\n * \"Extremely simple\" by design: a single shared secret both signs and verifies\n * tokens. When real identity providers are introduced, swap this for an\n * RS256/JWKS implementation of {@link IJwtSigner} — nothing else in the service\n * (or in consumers) changes, because the token shape stays the same.\n */\nexport class JwtSigner implements IJwtSigner {\n private static readonly algorithm: string = 'HS256';\n private readonly key: Uint8Array;\n\n public constructor(secret: string) {\n this.key = new TextEncoder().encode(secret);\n }\n\n public async sign(\n sub: string,\n guest: boolean,\n ttlSeconds: number,\n ): Promise<{ token: string; expiresAt: number }> {\n const issuedAt = Math.floor(Date.now() / 1000);\n const expiresAt = issuedAt + ttlSeconds;\n\n const token = await new SignJWT({ guest })\n .setProtectedHeader({ alg: JwtSigner.algorithm })\n .setSubject(sub)\n .setIssuedAt(issuedAt)\n .setExpirationTime(expiresAt)\n .sign(this.key);\n\n return { token, expiresAt };\n }\n\n public async verify(token: string): Promise<AccessTokenClaims> {\n const { payload } = await jwtVerify(token, this.key);\n\n return {\n sub: payload.sub as string,\n guest: payload.guest as boolean,\n iat: payload.iat as number,\n exp: payload.exp as number,\n };\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/signer/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { AccessTokenClaims } from '@textyly/crossly-client-auth-contracts';\n\n/**\n * Token signing/verification boundary — the lowest layer of the service\n * (the auth analogue of a repository). Implementations wrap a concrete JWT\n * library + key; the rest of the service depends only on this interface.\n */\nexport interface IJwtSigner {\n /** Sign an access token for `sub`, valid for `ttlSeconds`. Returns the token and its expiry. */\n sign(sub: string, guest: boolean, ttlSeconds: number): Promise<{ token: string; expiresAt: number }>;\n\n /** Verify a token and return its claims, or throw if it is invalid or expired. */\n verify(token: string): Promise<AccessTokenClaims>;\n}\n"]}
@@ -0,0 +1,25 @@
1
+ # Local dev Postgres for crossly.client.auth.service.
2
+ #
3
+ # docker compose up -d # start
4
+ # docker compose down # stop (keeps data)
5
+ # docker compose down -v # stop and wipe data
6
+ #
7
+ # Published on host port 5433 (not 5432) so it can coexist with a native Postgres
8
+ # that already owns 5432. Matches the default DATABASE_URL used by the migration
9
+ # runner and the service:
10
+ # postgres://crossly:crossly@127.0.0.1:5433/crossly_auth
11
+ services:
12
+ postgres:
13
+ image: postgres:17
14
+ container_name: crossly-auth-postgres
15
+ environment:
16
+ POSTGRES_USER: crossly
17
+ POSTGRES_PASSWORD: crossly
18
+ POSTGRES_DB: crossly_auth
19
+ ports:
20
+ - "5433:5432"
21
+ volumes:
22
+ - crossly-auth-pgdata:/var/lib/postgresql/data
23
+
24
+ volumes:
25
+ crossly-auth-pgdata:
@@ -0,0 +1,16 @@
1
+ -- Clients (authenticated accounts) for crossly.client.auth.service.
2
+ --
3
+ -- client_id == the token `sub`; immutable, reused from the guest's id on
4
+ -- promote-in-place. Identity is the (provider, provider_subject) pair — the
5
+ -- provider's stable subject. email is for display/contact only, NEVER a
6
+ -- matching key. Guests are not stored here — only clients that have logged in.
7
+
8
+ CREATE TABLE IF NOT EXISTS clients (
9
+ client_id UUID PRIMARY KEY,
10
+ provider TEXT NOT NULL,
11
+ provider_subject TEXT NOT NULL,
12
+ email TEXT,
13
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
14
+ last_login_at TIMESTAMPTZ,
15
+ UNIQUE (provider, provider_subject)
16
+ );
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "crossly.client.auth.service",
3
+ "version": "0.0.1",
4
+ "description": "TBD",
5
+ "main": "dist/app.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "compile": "tsc --version && tsc -p tsconfig.build.json",
9
+ "build:contracts": "tsc -p contracts/tsconfig.json",
10
+ "build": "npm i && npm run build:contracts && npm run compile",
11
+ "start": "npm run build && node dist/app.js",
12
+ "db:up": "docker compose up -d",
13
+ "db:down": "docker compose down",
14
+ "migrate": "npm run compile && node dist/db/migrate.js",
15
+ "compile:tests": "tsc --version && tsc -p tsconfig.test.json",
16
+ "build:tests": "npm run build && npm run compile:tests",
17
+ "test": "npm run build:tests && mocha 'dist-tests/tests/**/*.test.js'",
18
+ "test:unit": "npm run build:tests && mocha 'dist-tests/tests/unit/**/*.test.js'",
19
+ "test:integration": "npm run build:tests && mocha 'dist-tests/tests/integration/**/*.test.js'"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/textyly/crossly.client.auth.service.git"
24
+ },
25
+ "author": "textyly community",
26
+ "license": "Apache-2.0",
27
+ "dependencies": {
28
+ "@types/cors": "^2.8.19",
29
+ "cookie-parser": "^1.4.7",
30
+ "cors": "^2.8.6",
31
+ "dotenv": "^17.4.2",
32
+ "express": "^4.19.2",
33
+ "jose": "^5.9.6",
34
+ "openid-client": "^6.8.4",
35
+ "pg": "^8.13.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/chai": "^5.0.0",
39
+ "@types/cookie-parser": "^1.4.10",
40
+ "@types/express": "^4.17.21",
41
+ "@types/mocha": "^10.0.8",
42
+ "@types/node": "^20.0.0",
43
+ "@types/pg": "^8.11.10",
44
+ "@types/supertest": "^7.2.0",
45
+ "chai": "^5.1.1",
46
+ "mocha": "^10.7.3",
47
+ "supertest": "^7.2.2",
48
+ "typescript": "^5.6.2"
49
+ }
50
+ }
package/src/app.ts ADDED
@@ -0,0 +1,61 @@
1
+ import 'dotenv/config';
2
+ import pg from 'pg';
3
+ import { createApp } from './createApp.js';
4
+ import { JwtSigner } from './signer/jwtSigner.js';
5
+ import { PgClientRepository } from './repository/pgClientRepository.js';
6
+ import { databaseUrl, runMigrations } from './db/migrate.js';
7
+ import { loadConfig, loadGoogleConfig } from './config.js';
8
+ import { GoogleProvider } from './oidc/googleProvider.js';
9
+ import { NotConfiguredOidcProvider } from './oidc/notConfiguredOidcProvider.js';
10
+ import type { IOidcProvider } from './oidc/types.js';
11
+
12
+ const port = 5001;
13
+ // HS256 shared secret. MUST be overridden via AUTH_JWT_SECRET in any real environment.
14
+ const secret = process.env.AUTH_JWT_SECRET ?? 'dev-only-insecure-secret-change-me';
15
+
16
+ /** Build the real Google provider if configured, else a disabled placeholder. */
17
+ async function buildOidcProvider(): Promise<IOidcProvider> {
18
+ const google = loadGoogleConfig();
19
+ if (google.clientId && google.clientSecret) {
20
+ return GoogleProvider.create({
21
+ clientId: google.clientId,
22
+ clientSecret: google.clientSecret,
23
+ redirectUri: google.callbackUrl,
24
+ });
25
+ }
26
+
27
+ console.warn(
28
+ 'Google OIDC not configured (set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) — /auth/login is disabled',
29
+ );
30
+ return new NotConfiguredOidcProvider();
31
+ }
32
+
33
+ /**
34
+ * Entry point: apply database migrations, then build the app via the shared
35
+ * factory and listen.
36
+ *
37
+ * Migrations run on startup and are safe across several instances booting in
38
+ * parallel (an advisory lock serializes them — see db/migrate.ts), so no separate
39
+ * migration job is required. The service will not start serving until the schema
40
+ * is up to date, which means a reachable Postgres is now required to boot.
41
+ */
42
+ async function main(): Promise<void> {
43
+ await runMigrations();
44
+
45
+ // One pool for the app's lifetime, backing the client repository.
46
+ const pool = new pg.Pool({ connectionString: databaseUrl() });
47
+ const signer = new JwtSigner(secret);
48
+ const clients = new PgClientRepository(pool);
49
+ const oidc = await buildOidcProvider();
50
+ const config = loadConfig();
51
+ const app = createApp({ signer, clients, oidc, config });
52
+
53
+ app.listen(port, () => {
54
+ console.log(`crossly.client.auth.service listening on port ${port}`);
55
+ });
56
+ }
57
+
58
+ main().catch((error) => {
59
+ console.error('failed to start crossly.client.auth.service:', error);
60
+ process.exit(1);
61
+ });
package/src/config.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Service configuration, read from the environment with dev-friendly defaults.
3
+ * Secrets/URLs come from env (k8s Secret/ConfigMap later); the defaults make
4
+ * local dev work with zero setup (except Google login, which needs real creds).
5
+ */
6
+
7
+ /** httpOnly cookie holding the session JWT (guest or authenticated). */
8
+ export const SESSION_COOKIE = 'crossly_session';
9
+
10
+ /** Short-lived signed cookie holding the OAuth `state` + PKCE `codeVerifier`. */
11
+ export const OAUTH_COOKIE = 'crossly_oauth';
12
+
13
+ /** Runtime config for cookies, CORS and post-login redirects. */
14
+ export interface AuthConfig {
15
+ /** Secret used to sign the short-lived OAuth cookie. */
16
+ cookieSecret: string;
17
+ /** Where the browser is sent after a successful login / after logout. */
18
+ uiRedirectUrl: string;
19
+ /** Allowed browser origin for credentialed CORS requests. */
20
+ corsOrigin: string;
21
+ /** Whether cookies carry the `Secure` attribute (true behind HTTPS). */
22
+ secureCookies: boolean;
23
+ }
24
+
25
+ /** Google OAuth client config; clientId/secret absent ⇒ login is disabled. */
26
+ export interface GoogleConfig {
27
+ clientId?: string;
28
+ clientSecret?: string;
29
+ callbackUrl: string;
30
+ }
31
+
32
+ export function loadConfig(): AuthConfig {
33
+ // Where the browser lands after login. May include a path (e.g. the static
34
+ // UI entry); the CORS origin is derived from just its scheme+host+port, since
35
+ // the browser's Origin header never carries a path.
36
+ const uiRedirectUrl = process.env.UI_URL ?? 'http://localhost:5000/dist/index.html';
37
+ return {
38
+ cookieSecret: process.env.COOKIE_SECRET ?? 'dev-only-cookie-secret-change-me',
39
+ uiRedirectUrl,
40
+ corsOrigin: process.env.CORS_ORIGIN ?? new URL(uiRedirectUrl).origin,
41
+ secureCookies: (process.env.SECURE_COOKIES ?? 'false') === 'true',
42
+ };
43
+ }
44
+
45
+ export function loadGoogleConfig(): GoogleConfig {
46
+ return {
47
+ clientId: process.env.GOOGLE_CLIENT_ID,
48
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
49
+ callbackUrl: process.env.GOOGLE_CALLBACK_URL ?? 'http://localhost:5001/api/v1/auth/callback',
50
+ };
51
+ }
@@ -0,0 +1,237 @@
1
+ import { Router, type CookieOptions, type Request, type Response } from 'express';
2
+ import { randomBytes } from 'node:crypto';
3
+ import type { IAuthManager } from '../managers/types.js';
4
+ import type { IOidcProvider } from '../oidc/types.js';
5
+ import { type AuthConfig, OAUTH_COOKIE, SESSION_COOKIE } from '../config.js';
6
+
7
+ /** The OAuth state/PKCE cookie only needs to outlive the redirect round-trip. */
8
+ const OAUTH_COOKIE_TTL_MS = 10 * 60 * 1000;
9
+
10
+ /**
11
+ * HTTP surface for client authentication (BFF cookie model).
12
+ *
13
+ * The session JWT rides in an httpOnly cookie ({@link SESSION_COOKIE}) — the
14
+ * browser never sees it in JS — so endpoints derive identity from the cookie and
15
+ * the UI learns who it is via `GET /auth/me`.
16
+ *
17
+ * Mounted under `/auth`:
18
+ * POST /auth/guest -> mint a guest session; set the session cookie
19
+ * POST /auth/refresh -> roll the session cookie forward
20
+ * GET /auth/validate -> verify the cookie; X-Client-Id / X-Guest (gateway ForwardAuth)
21
+ * GET /auth/me -> { clientId, guest, email? } for the UI
22
+ * GET /auth/login -> begin Authorization Code + PKCE; redirect to the provider
23
+ * GET /auth/callback -> finish login; promote the guest in place; set the session cookie
24
+ * POST /auth/logout -> clear the session cookie (UI then mints a fresh guest)
25
+ */
26
+ export class AuthController {
27
+ public readonly router: Router;
28
+
29
+ public constructor(
30
+ private readonly manager: IAuthManager,
31
+ private readonly oidc: IOidcProvider,
32
+ private readonly config: AuthConfig,
33
+ ) {
34
+ this.router = Router();
35
+ this.registerRoutes();
36
+ }
37
+
38
+ private registerRoutes(): void {
39
+ this.router.post('/guest', this.createGuestSession);
40
+ this.router.post('/refresh', this.refreshSession);
41
+ this.router.get('/validate', this.validate);
42
+ this.router.get('/me', this.me);
43
+ this.router.get('/login', this.login);
44
+ this.router.get('/callback', this.callback);
45
+ this.router.post('/logout', this.logout);
46
+ }
47
+
48
+ private readonly createGuestSession = async (req: Request, res: Response): Promise<void> => {
49
+ // Idempotent: if a valid session already exists (guest OR authenticated),
50
+ // return it instead of minting a new guest — so this can never downgrade a
51
+ // logged-in user or churn an existing guest. To abandon a session, use
52
+ // POST /auth/logout (which clears the cookie); the next call then creates one.
53
+ const existing = this.sessionToken(req);
54
+ if (existing) {
55
+ try {
56
+ const claims = await this.manager.validate(existing);
57
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
58
+ return;
59
+ } catch {
60
+ // Invalid/expired cookie -> fall through and mint a fresh guest.
61
+ }
62
+ }
63
+
64
+ const session = await this.manager.createGuestSession();
65
+ this.setSessionCookie(res, session.token, session.expiresAt);
66
+ res.status(201).json({ clientId: session.clientId, guest: true });
67
+ };
68
+
69
+ private readonly refreshSession = async (req: Request, res: Response): Promise<void> => {
70
+ const token = this.sessionToken(req);
71
+ if (!token) {
72
+ res.status(401).json({ error: 'no session' });
73
+ return;
74
+ }
75
+
76
+ try {
77
+ const session = await this.manager.refreshSession(token);
78
+ this.setSessionCookie(res, session.token, session.expiresAt);
79
+ const claims = await this.manager.validate(session.token);
80
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
81
+ } catch {
82
+ this.clearSessionCookie(res);
83
+ res.status(401).json({ error: 'invalid or expired session' });
84
+ }
85
+ };
86
+
87
+ private readonly validate = async (req: Request, res: Response): Promise<void> => {
88
+ const token = this.sessionToken(req);
89
+ if (!token) {
90
+ res.status(401).json({ error: 'no session' });
91
+ return;
92
+ }
93
+
94
+ try {
95
+ const claims = await this.manager.validate(token);
96
+ // The gateway (ForwardAuth) copies these onto the proxied request so
97
+ // downstream services receive a trusted identity they didn't have to verify.
98
+ res.setHeader('X-Client-Id', claims.sub);
99
+ res.setHeader('X-Guest', String(claims.guest));
100
+ res.status(200).json({ clientId: claims.sub, guest: claims.guest });
101
+ } catch {
102
+ res.status(401).json({ error: 'invalid or expired session' });
103
+ }
104
+ };
105
+
106
+ private readonly me = async (req: Request, res: Response): Promise<void> => {
107
+ const token = this.sessionToken(req);
108
+ if (!token) {
109
+ res.status(401).json({ error: 'no session' });
110
+ return;
111
+ }
112
+
113
+ try {
114
+ const info = await this.manager.describeSession(token);
115
+ res.status(200).json(info);
116
+ } catch {
117
+ res.status(401).json({ error: 'invalid or expired session' });
118
+ }
119
+ };
120
+
121
+ private readonly login = async (_req: Request, res: Response): Promise<void> => {
122
+ // Generic, provider-agnostic CSRF state + PKCE verifier; the provider turns
123
+ // the verifier into a code_challenge when building the authorize URL.
124
+ const state = randomBytes(16).toString('base64url');
125
+ const codeVerifier = randomBytes(32).toString('base64url');
126
+
127
+ try {
128
+ const url = await this.oidc.authorizeUrl(state, codeVerifier);
129
+ res.cookie(OAUTH_COOKIE, JSON.stringify({ state, codeVerifier }), {
130
+ ...this.baseCookieOptions(),
131
+ // path '/' (not the mount path) so it survives wherever the auth
132
+ // routes are mounted; it's short-lived, signed and single-use.
133
+ maxAge: OAUTH_COOKIE_TTL_MS,
134
+ signed: true,
135
+ });
136
+ res.redirect(302, url);
137
+ } catch {
138
+ res.status(503).json({ error: 'login not configured' });
139
+ }
140
+ };
141
+
142
+ private readonly callback = async (req: Request, res: Response): Promise<void> => {
143
+ const code = typeof req.query.code === 'string' ? req.query.code : undefined;
144
+ const state = typeof req.query.state === 'string' ? req.query.state : undefined;
145
+ const stash = this.readOauthCookie(req);
146
+
147
+ // The OAuth cookie is single-use.
148
+ res.clearCookie(OAUTH_COOKIE, { path: '/' });
149
+
150
+ if (!code || !state || !stash || stash.state !== state) {
151
+ res.status(400).json({ error: 'invalid oauth callback' });
152
+ return;
153
+ }
154
+
155
+ try {
156
+ // Pass ALL callback params (code, state, iss, …) through to the provider.
157
+ const queryIndex = req.originalUrl.indexOf('?');
158
+ const callbackParams = new URLSearchParams(
159
+ queryIndex >= 0 ? req.originalUrl.slice(queryIndex + 1) : '',
160
+ );
161
+ const identity = await this.oidc.exchangeCode(callbackParams, stash.codeVerifier);
162
+ // Promote the current guest in place if one is present on this device.
163
+ const guestClientId = await this.guestClientId(req);
164
+ const resolved = await this.manager.resolveLogin(identity, guestClientId);
165
+
166
+ const session = await this.manager.createAuthenticatedSession(resolved.clientId);
167
+ // Overwriting the session cookie also "clears" the guest session.
168
+ this.setSessionCookie(res, session.token, session.expiresAt);
169
+ res.redirect(302, this.config.uiRedirectUrl);
170
+ } catch (e) {
171
+ console.log(e);
172
+ res.status(400).json({ error: 'login failed' });
173
+ }
174
+ };
175
+
176
+ private readonly logout = async (_req: Request, res: Response): Promise<void> => {
177
+ this.clearSessionCookie(res);
178
+ res.status(204).end();
179
+ };
180
+
181
+ // --- cookie helpers ---
182
+
183
+ private baseCookieOptions(): CookieOptions {
184
+ return {
185
+ httpOnly: true,
186
+ sameSite: 'lax',
187
+ secure: this.config.secureCookies,
188
+ path: '/',
189
+ };
190
+ }
191
+
192
+ private setSessionCookie(res: Response, token: string, expiresAt: number): void {
193
+ res.cookie(SESSION_COOKIE, token, {
194
+ ...this.baseCookieOptions(),
195
+ maxAge: Math.max(0, expiresAt * 1000 - Date.now()),
196
+ });
197
+ }
198
+
199
+ private clearSessionCookie(res: Response): void {
200
+ res.clearCookie(SESSION_COOKIE, { path: '/' });
201
+ }
202
+
203
+ private sessionToken(req: Request): string | undefined {
204
+ const value = req.cookies?.[SESSION_COOKIE];
205
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
206
+ }
207
+
208
+ private readOauthCookie(req: Request): { state: string; codeVerifier: string } | undefined {
209
+ const raw = req.signedCookies?.[OAUTH_COOKIE];
210
+ if (typeof raw !== 'string') {
211
+ return undefined;
212
+ }
213
+ try {
214
+ const parsed = JSON.parse(raw) as { state?: unknown; codeVerifier?: unknown };
215
+ if (typeof parsed.state === 'string' && typeof parsed.codeVerifier === 'string') {
216
+ return { state: parsed.state, codeVerifier: parsed.codeVerifier };
217
+ }
218
+ } catch {
219
+ // fall through
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ /** The current session's clientId iff it is a (still valid) guest — for promote-in-place. */
225
+ private async guestClientId(req: Request): Promise<string | undefined> {
226
+ const token = this.sessionToken(req);
227
+ if (!token) {
228
+ return undefined;
229
+ }
230
+ try {
231
+ const claims = await this.manager.validate(token);
232
+ return claims.guest ? claims.sub : undefined;
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+ }