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,45 @@
1
+ import express, { type Express, type Request, type Response } 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
+ import type { IJwtSigner } from './signer/types.js';
7
+ import type { IClientRepository } from './repository/types.js';
8
+ import type { IOidcProvider } from './oidc/types.js';
9
+ import type { AuthConfig } from './config.js';
10
+
11
+ /** Collaborators the app is built from. Injected so tests can swap in fakes. */
12
+ export interface AppDependencies {
13
+ signer: IJwtSigner;
14
+ clients: IClientRepository;
15
+ oidc: IOidcProvider;
16
+ config: AuthConfig;
17
+ }
18
+
19
+ /**
20
+ * Builds the configured Express application (CORS w/ credentials, JSON + cookie
21
+ * parsing, /health, and the auth routes) from its dependencies, WITHOUT binding
22
+ * a port.
23
+ *
24
+ * The entry point (app.ts) and the integration tests share this factory so they
25
+ * exercise the same wiring; only the injected collaborators differ (real
26
+ * Postgres + Google at runtime; in-memory repo + fake provider in tests).
27
+ */
28
+ export function createApp({ signer, clients, oidc, config }: AppDependencies): Express {
29
+ const app = express();
30
+
31
+ // Credentialed CORS so the browser sends/receives the httpOnly session cookie.
32
+ app.use(cors({ origin: config.corsOrigin, credentials: true }));
33
+ app.use(express.json());
34
+ app.use(cookieParser(config.cookieSecret));
35
+
36
+ app.get('/health', (_req: Request, res: Response) => {
37
+ res.json({ status: 'ok' });
38
+ });
39
+
40
+ const manager = new AuthManager(signer, clients);
41
+ const controller = new AuthController(manager, oidc, config);
42
+ app.use('/api/v1/auth', controller.router);
43
+
44
+ return app;
45
+ }
@@ -0,0 +1,106 @@
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
+ /**
7
+ * Forward-only migration runner, safe to run from several instances at once.
8
+ *
9
+ * Applies every `.sql` file in ./migrations (sorted by filename) that has not yet
10
+ * been recorded in `schema_migrations`, each in its own transaction. A cluster-wide
11
+ * Postgres advisory lock serializes migrators: the first caller applies the pending
12
+ * files; any others block on the lock, then acquire it, see everything already
13
+ * applied, and do nothing. The lock is session-scoped and held on a single dedicated
14
+ * connection for the whole run, so it also auto-releases if the process crashes.
15
+ *
16
+ * Connection comes from DATABASE_URL; the default targets the local dev Postgres in
17
+ * docker-compose.yml. Run standalone with `npm run migrate`, or it runs on service
18
+ * startup (see app.ts).
19
+ */
20
+ // Host port 5433 (see docker-compose.yml) so the dev container can coexist with a
21
+ // native Postgres that already owns 5432.
22
+ const DEFAULT_DATABASE_URL = 'postgres://crossly:crossly@127.0.0.1:5433/crossly_auth';
23
+ const MIGRATIONS_DIR = join(process.cwd(), 'migrations');
24
+
25
+ /** The Postgres connection string for the service (env override, else dev default). */
26
+ export function databaseUrl(): string {
27
+ return process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL;
28
+ }
29
+
30
+ // Constant key for the advisory lock that serializes migrations cluster-wide.
31
+ // Any fixed value works as long as it is unique to this concern.
32
+ const MIGRATION_LOCK_KEY = 4242420001;
33
+
34
+ export async function runMigrations(connectionString: string = databaseUrl()): Promise<void> {
35
+ const pool = new pg.Pool({ connectionString });
36
+ try {
37
+ await migrateWithPool(pool);
38
+ } finally {
39
+ await pool.end();
40
+ }
41
+ }
42
+
43
+ async function migrateWithPool(pool: pg.Pool): Promise<void> {
44
+ // Hold the advisory lock and run every migration on ONE connection so the
45
+ // session-scoped lock spans the entire run.
46
+ const client = await pool.connect();
47
+ try {
48
+ await client.query('SELECT pg_advisory_lock($1::bigint)', [MIGRATION_LOCK_KEY]);
49
+
50
+ await client.query(
51
+ `CREATE TABLE IF NOT EXISTS schema_migrations (
52
+ name TEXT PRIMARY KEY,
53
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
54
+ )`,
55
+ );
56
+
57
+ const files = readdirSync(MIGRATIONS_DIR)
58
+ .filter((file) => file.endsWith('.sql'))
59
+ .sort();
60
+
61
+ for (const file of files) {
62
+ const applied = await client.query('SELECT 1 FROM schema_migrations WHERE name = $1', [
63
+ file,
64
+ ]);
65
+ if ((applied.rowCount ?? 0) > 0) {
66
+ console.log(`skip ${file}`);
67
+ continue;
68
+ }
69
+
70
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
71
+ try {
72
+ await client.query('BEGIN');
73
+ await client.query(sql);
74
+ await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
75
+ await client.query('COMMIT');
76
+ console.log(`apply ${file}`);
77
+ } catch (error) {
78
+ await client.query('ROLLBACK');
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ console.log('migrations complete');
84
+ } finally {
85
+ // Best-effort explicit unlock; releasing the connection also drops the
86
+ // session lock, so a failure here is harmless.
87
+ try {
88
+ await client.query('SELECT pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY]);
89
+ } catch {
90
+ // ignore
91
+ }
92
+ client.release();
93
+ }
94
+ }
95
+
96
+ // CLI entrypoint: only runs when invoked directly (`node dist/db/migrate.js`),
97
+ // not when imported by the app for startup migration.
98
+ const invokedDirectly =
99
+ process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
100
+
101
+ if (invokedDirectly) {
102
+ runMigrations().catch((error) => {
103
+ console.error('migration failed:', error);
104
+ process.exit(1);
105
+ });
106
+ }
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type {
3
+ AccessTokenClaims,
4
+ GuestSessionResponse,
5
+ MeResponse,
6
+ } from '@textyly/crossly-client-auth-contracts';
7
+ import type { IAuthManager, ResolvedLogin } from './types.js';
8
+ import type { IJwtSigner } from '../signer/types.js';
9
+ import type { IClientRepository } from '../repository/types.js';
10
+ import type { OidcIdentity } from '../oidc/types.js';
11
+
12
+ /**
13
+ * Sessions are valid for 1 year. Combined with refresh-on-use
14
+ * ({@link AuthManager.refreshSession}), an active anonymous user effectively
15
+ * never loses their session — it only lapses after a full year of inactivity.
16
+ */
17
+ const SESSION_TTL_SECONDS: number = 60 * 60 * 24 * 365;
18
+
19
+ /**
20
+ * Default {@link IAuthManager} implementation.
21
+ *
22
+ * - {@link createGuestSession} mints a brand-new anonymous session (new clientId).
23
+ * - {@link refreshSession} re-issues a token for an existing session, preserving
24
+ * its identity but resetting the expiry (a sliding/rolling session).
25
+ * - {@link validate} verifies a token and returns its claims (used by the gateway).
26
+ * - {@link resolveLogin} maps a verified provider identity to a stable clientId,
27
+ * promoting the current guest in place on first login.
28
+ *
29
+ * Token signing/verification is delegated to the injected {@link IJwtSigner};
30
+ * account persistence to the injected {@link IClientRepository}.
31
+ */
32
+ export class AuthManager implements IAuthManager {
33
+ public constructor(
34
+ private readonly signer: IJwtSigner,
35
+ private readonly clients: IClientRepository,
36
+ ) {}
37
+
38
+ public async createGuestSession(): Promise<GuestSessionResponse> {
39
+ const clientId = randomUUID();
40
+ const { token, expiresAt } = await this.signer.sign(clientId, true, SESSION_TTL_SECONDS);
41
+
42
+ return { token, clientId, expiresAt };
43
+ }
44
+
45
+ public async createAuthenticatedSession(clientId: string): Promise<GuestSessionResponse> {
46
+ const { token, expiresAt } = await this.signer.sign(clientId, false, SESSION_TTL_SECONDS);
47
+ return { token, clientId, expiresAt };
48
+ }
49
+
50
+ public async describeSession(token: string): Promise<MeResponse> {
51
+ const claims = await this.signer.verify(token);
52
+ if (claims.guest) {
53
+ return { clientId: claims.sub, guest: true };
54
+ }
55
+
56
+ // Authenticated: surface the stored email (display only) if we have one.
57
+ const client = await this.clients.findById(claims.sub);
58
+ return { clientId: claims.sub, guest: false, email: client?.email };
59
+ }
60
+
61
+ public async refreshSession(token: string): Promise<GuestSessionResponse> {
62
+ // Verify the current token; throws if it is invalid or expired.
63
+ const claims = await this.signer.verify(token);
64
+
65
+ // Re-issue for the SAME identity with a fresh expiry, so an active user's
66
+ // session keeps rolling forward.
67
+ const reissued = await this.signer.sign(claims.sub, claims.guest, SESSION_TTL_SECONDS);
68
+
69
+ return { token: reissued.token, clientId: claims.sub, expiresAt: reissued.expiresAt };
70
+ }
71
+
72
+ public validate(token: string): Promise<AccessTokenClaims> {
73
+ // Verify signature + expiry; resolves with the claims or rejects.
74
+ return this.signer.verify(token);
75
+ }
76
+
77
+ public async resolveLogin(
78
+ identity: OidcIdentity,
79
+ guestClientId?: string,
80
+ ): Promise<ResolvedLogin> {
81
+ const existing = await this.clients.findByProvider(identity.provider, identity.subject);
82
+ if (existing) {
83
+ // Returning user on this provider — recognized on any device. The
84
+ // current guest id (if any) is discarded by the caller; this account's
85
+ // id wins.
86
+ await this.clients.touchLastLogin(existing.clientId);
87
+ return { clientId: existing.clientId, created: false, promoted: false };
88
+ }
89
+
90
+ // New external identity. Promote the current guest in place if one is
91
+ // present (the account adopts the guest's id, so the guest's data on this
92
+ // device carries over with no migration); otherwise mint a fresh id.
93
+ const promoted = guestClientId !== undefined;
94
+ const clientId = guestClientId ?? randomUUID();
95
+
96
+ await this.clients.create({
97
+ clientId,
98
+ provider: identity.provider,
99
+ providerSubject: identity.subject,
100
+ email: identity.email,
101
+ });
102
+
103
+ return { clientId, created: true, promoted };
104
+ }
105
+ }
@@ -0,0 +1,59 @@
1
+ import type {
2
+ AccessTokenClaims,
3
+ GuestSessionResponse,
4
+ MeResponse,
5
+ } from '@textyly/crossly-client-auth-contracts';
6
+ import type { OidcIdentity } from '../oidc/types.js';
7
+
8
+ /**
9
+ * Outcome of resolving a verified provider identity to a stable client.
10
+ */
11
+ export interface ResolvedLogin {
12
+ /** The stable client id (== token `sub`) this login resolves to. */
13
+ clientId: string;
14
+ /** True if a new `clients` row was written (a brand-new account or a promotion). */
15
+ created: boolean;
16
+ /** True if an existing guest was promoted in place (its id became the account). */
17
+ promoted: boolean;
18
+ }
19
+
20
+ /**
21
+ * Business operations for client authentication. Sits between the controllers
22
+ * (HTTP) and the signer (token issuance/verification) + client repository.
23
+ */
24
+ export interface IAuthManager {
25
+ /** Create a fresh anonymous guest session (new clientId + signed token). */
26
+ createGuestSession(): Promise<GuestSessionResponse>;
27
+
28
+ /** Mint an authenticated (guest:false) session token for an existing clientId. */
29
+ createAuthenticatedSession(clientId: string): Promise<GuestSessionResponse>;
30
+
31
+ /**
32
+ * Verify a token and describe the session for the UI: `{ clientId, guest, email? }`.
33
+ * Email is looked up for authenticated users; guests have none. Throws if the
34
+ * token is invalid or expired.
35
+ */
36
+ describeSession(token: string): Promise<MeResponse>;
37
+
38
+ /**
39
+ * Re-issue a token for the session identified by `token`, preserving its
40
+ * identity (clientId) but resetting the expiry. Throws if the token is
41
+ * invalid or expired.
42
+ */
43
+ refreshSession(token: string): Promise<GuestSessionResponse>;
44
+
45
+ /**
46
+ * Verify a token and return its claims. Throws if the token is invalid or
47
+ * expired. Used by the gateway (ForwardAuth) to turn a token into a trusted
48
+ * clientId.
49
+ */
50
+ validate(token: string): Promise<AccessTokenClaims>;
51
+
52
+ /**
53
+ * Resolve a verified provider identity to a stable clientId:
54
+ * - existing `(provider, subject)` → that client (returning user, any device);
55
+ * - else, a guest is present → **promote in place** (reuse `guestClientId`);
56
+ * - else → create a brand-new client.
57
+ */
58
+ resolveLogin(identity: OidcIdentity, guestClientId?: string): Promise<ResolvedLogin>;
59
+ }
@@ -0,0 +1,72 @@
1
+ import * as oidc from 'openid-client';
2
+ import type { IOidcProvider, OidcIdentity } from './types.js';
3
+
4
+ export interface GoogleProviderOptions {
5
+ clientId: string;
6
+ clientSecret: string;
7
+ /** Must exactly match an Authorized redirect URI on the Google OAuth client. */
8
+ redirectUri: string;
9
+ }
10
+
11
+ const GOOGLE_ISSUER = new URL('https://accounts.google.com');
12
+ const SCOPE = 'openid email profile';
13
+
14
+ /**
15
+ * {@link IOidcProvider} backed by Google via `openid-client` (Authorization Code
16
+ * + PKCE). Created with {@link GoogleProvider.create}, which performs OIDC
17
+ * discovery once against Google's well-known configuration.
18
+ *
19
+ * State/CSRF is handled by the controller (it owns the cookie), so the code
20
+ * exchange here skips openid-client's own state check.
21
+ */
22
+ export class GoogleProvider implements IOidcProvider {
23
+ private constructor(
24
+ private readonly config: oidc.Configuration,
25
+ private readonly redirectUri: string,
26
+ ) {}
27
+
28
+ public static async create(options: GoogleProviderOptions): Promise<GoogleProvider> {
29
+ const config = await oidc.discovery(GOOGLE_ISSUER, options.clientId, options.clientSecret);
30
+ return new GoogleProvider(config, options.redirectUri);
31
+ }
32
+
33
+ public async authorizeUrl(state: string, codeVerifier: string): Promise<string> {
34
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
35
+ const url = oidc.buildAuthorizationUrl(this.config, {
36
+ redirect_uri: this.redirectUri,
37
+ scope: SCOPE,
38
+ code_challenge: codeChallenge,
39
+ code_challenge_method: 'S256',
40
+ state,
41
+ });
42
+ return url.href;
43
+ }
44
+
45
+ public async exchangeCode(
46
+ callbackParams: URLSearchParams,
47
+ codeVerifier: string,
48
+ ): Promise<OidcIdentity> {
49
+ // Rebuild the exact callback URL openid-client expects: our redirect URI
50
+ // plus ALL of the provider's returned params (code, state, iss, …) so its
51
+ // response validation (incl. the RFC 9207 issuer check) passes.
52
+ const currentUrl = new URL(this.redirectUri);
53
+ currentUrl.search = callbackParams.toString();
54
+
55
+ const tokens = await oidc.authorizationCodeGrant(this.config, currentUrl, {
56
+ pkceCodeVerifier: codeVerifier,
57
+ // The controller already validated `state` against its cookie.
58
+ expectedState: oidc.skipStateCheck,
59
+ });
60
+
61
+ const claims = tokens.claims();
62
+ if (!claims?.sub) {
63
+ throw new Error('google login did not return a subject');
64
+ }
65
+
66
+ return {
67
+ provider: 'google',
68
+ subject: claims.sub,
69
+ email: typeof claims.email === 'string' ? claims.email : undefined,
70
+ };
71
+ }
72
+ }
@@ -0,0 +1,16 @@
1
+ import type { IOidcProvider, OidcIdentity } from './types.js';
2
+
3
+ /**
4
+ * Placeholder {@link IOidcProvider} used when no real provider is configured
5
+ * (e.g. Google client id/secret absent in local dev). Guest sessions keep
6
+ * working; any attempt to log in fails loudly so the controller can return 503.
7
+ */
8
+ export class NotConfiguredOidcProvider implements IOidcProvider {
9
+ public authorizeUrl(): Promise<string> {
10
+ throw new Error('OIDC login is not configured');
11
+ }
12
+
13
+ public exchangeCode(): Promise<OidcIdentity> {
14
+ throw new Error('OIDC login is not configured');
15
+ }
16
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The identity an OIDC provider asserts after a successful login.
3
+ *
4
+ * `(provider, subject)` is the client identity key — `subject` is the provider's
5
+ * stable user id (their `sub`), which is the same across all of that user's
6
+ * devices. `email` is provider-reported and kept for display/contact only; it is
7
+ * NEVER used to match or link clients.
8
+ */
9
+ export interface OidcIdentity {
10
+ /** Stable provider name, e.g. 'google'. Part of the client identity key. */
11
+ provider: string;
12
+ /** The provider's stable subject id for this user (their `sub`). */
13
+ subject: string;
14
+ /** Provider-reported email — display/contact only, never a matching key. */
15
+ email?: string;
16
+ }
17
+
18
+ /**
19
+ * Boundary to an OIDC identity provider (Google now; GitHub / Facebook / a broker
20
+ * like Auth0 later). Implementations own the provider-specific OAuth/OIDC
21
+ * mechanics; the rest of the service depends only on this interface, so adding a
22
+ * provider is a single new class with no changes elsewhere.
23
+ */
24
+ export interface IOidcProvider {
25
+ /**
26
+ * Build the provider's authorization URL to redirect the user to
27
+ * (Authorization Code flow + PKCE). `state` and `codeVerifier` are generated
28
+ * by the caller and stashed for the callback.
29
+ */
30
+ authorizeUrl(state: string, codeVerifier: string): Promise<string>;
31
+
32
+ /**
33
+ * Exchange the authorization callback for the verified {@link OidcIdentity}.
34
+ *
35
+ * `callbackParams` are ALL query parameters the provider returned on the
36
+ * redirect (code, state, iss, …) — passed whole so the OIDC library can
37
+ * validate them (e.g. the RFC 9207 `iss`). `codeVerifier` is the stashed PKCE
38
+ * verifier.
39
+ */
40
+ exchangeCode(callbackParams: URLSearchParams, codeVerifier: string): Promise<OidcIdentity>;
41
+ }
@@ -0,0 +1,72 @@
1
+ import type { ClientRecord, IClientRepository, NewClient } from './types.js';
2
+
3
+ /**
4
+ * In-memory implementation of {@link IClientRepository}.
5
+ *
6
+ * State lives in two Maps (by clientId, and a (provider, subject) -> clientId
7
+ * index) and is lost on restart. Used by the unit tests and for running locally
8
+ * without a database; {@link PgClientRepository} is the durable implementation
9
+ * wired into the service at runtime. Records are cloned on the way in and out so
10
+ * callers cannot mutate stored state by reference.
11
+ */
12
+ export class InMemoryClientRepository implements IClientRepository {
13
+ private readonly byClientId: Map<string, ClientRecord> = new Map();
14
+ private readonly byIdentity: Map<string, string> = new Map();
15
+
16
+ public async findByProvider(
17
+ provider: string,
18
+ providerSubject: string,
19
+ ): Promise<ClientRecord | undefined> {
20
+ const clientId = this.byIdentity.get(this.identityKey(provider, providerSubject));
21
+ if (!clientId) {
22
+ return undefined;
23
+ }
24
+
25
+ const found = this.byClientId.get(clientId);
26
+ return found ? { ...found } : undefined;
27
+ }
28
+
29
+ public async findById(clientId: string): Promise<ClientRecord | undefined> {
30
+ const found = this.byClientId.get(clientId);
31
+ return found ? { ...found } : undefined;
32
+ }
33
+
34
+ public async create(client: NewClient): Promise<ClientRecord> {
35
+ const idKey = this.identityKey(client.provider, client.providerSubject);
36
+
37
+ if (this.byClientId.has(client.clientId)) {
38
+ throw new Error(`client already exists: ${client.clientId}`);
39
+ }
40
+ if (this.byIdentity.has(idKey)) {
41
+ throw new Error(`identity already mapped: ${client.provider}/${client.providerSubject}`);
42
+ }
43
+
44
+ const record: ClientRecord = {
45
+ clientId: client.clientId,
46
+ provider: client.provider,
47
+ providerSubject: client.providerSubject,
48
+ email: client.email,
49
+ createdAt: new Date(),
50
+ lastLoginAt: undefined,
51
+ };
52
+
53
+ this.byClientId.set(record.clientId, record);
54
+ this.byIdentity.set(idKey, record.clientId);
55
+ return { ...record };
56
+ }
57
+
58
+ public async touchLastLogin(clientId: string): Promise<void> {
59
+ const existing = this.byClientId.get(clientId);
60
+ if (!existing) {
61
+ return;
62
+ }
63
+
64
+ existing.lastLoginAt = new Date();
65
+ }
66
+
67
+ // A unique key for a (provider, subject) pair. The pair is JSON-encoded so no
68
+ // combination of provider/subject characters can produce a colliding key.
69
+ private identityKey(provider: string, providerSubject: string): string {
70
+ return JSON.stringify([provider, providerSubject]);
71
+ }
72
+ }
@@ -0,0 +1,75 @@
1
+ import pg from 'pg';
2
+ import type { ClientRecord, IClientRepository, NewClient } from './types.js';
3
+
4
+ /** Raw row shape returned from the `clients` table. */
5
+ interface ClientRow {
6
+ client_id: string;
7
+ provider: string;
8
+ provider_subject: string;
9
+ email: string | null;
10
+ created_at: Date;
11
+ last_login_at: Date | null;
12
+ }
13
+
14
+ /**
15
+ * PostgreSQL-backed implementation of {@link IClientRepository}.
16
+ *
17
+ * A connected {@link pg.Pool} is injected; its lifecycle (and the schema, via the
18
+ * migration runner) is owned by the composition root. The UNIQUE (provider,
19
+ * provider_subject) constraint enforces one client per external identity at the
20
+ * database level, so a concurrent duplicate `create` rejects rather than racing.
21
+ */
22
+ export class PgClientRepository implements IClientRepository {
23
+ public constructor(private readonly pool: pg.Pool) {}
24
+
25
+ public async findByProvider(
26
+ provider: string,
27
+ providerSubject: string,
28
+ ): Promise<ClientRecord | undefined> {
29
+ const result = await this.pool.query<ClientRow>(
30
+ 'SELECT * FROM clients WHERE provider = $1 AND provider_subject = $2',
31
+ [provider, providerSubject],
32
+ );
33
+
34
+ const row = result.rows[0];
35
+ return row ? this.toDomain(row) : undefined;
36
+ }
37
+
38
+ public async findById(clientId: string): Promise<ClientRecord | undefined> {
39
+ const result = await this.pool.query<ClientRow>(
40
+ 'SELECT * FROM clients WHERE client_id = $1',
41
+ [clientId],
42
+ );
43
+
44
+ const row = result.rows[0];
45
+ return row ? this.toDomain(row) : undefined;
46
+ }
47
+
48
+ public async create(client: NewClient): Promise<ClientRecord> {
49
+ const result = await this.pool.query<ClientRow>(
50
+ `INSERT INTO clients (client_id, provider, provider_subject, email)
51
+ VALUES ($1, $2, $3, $4)
52
+ RETURNING *`,
53
+ [client.clientId, client.provider, client.providerSubject, client.email ?? null],
54
+ );
55
+
56
+ return this.toDomain(result.rows[0]);
57
+ }
58
+
59
+ public async touchLastLogin(clientId: string): Promise<void> {
60
+ await this.pool.query('UPDATE clients SET last_login_at = now() WHERE client_id = $1', [
61
+ clientId,
62
+ ]);
63
+ }
64
+
65
+ private toDomain(row: ClientRow): ClientRecord {
66
+ return {
67
+ clientId: row.client_id,
68
+ provider: row.provider,
69
+ providerSubject: row.provider_subject,
70
+ email: row.email ?? undefined,
71
+ createdAt: row.created_at,
72
+ lastLoginAt: row.last_login_at ?? undefined,
73
+ };
74
+ }
75
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A persisted client (account).
3
+ *
4
+ * `clientId` equals the token `sub` and is immutable for the life of the client
5
+ * (it is reused from the guest's id on promote-in-place). Identity is the pair
6
+ * `(provider, providerSubject)` — the provider's stable subject is matched
7
+ * directly; email is stored for display/contact only and is NEVER a matching key.
8
+ *
9
+ * Guests are NOT stored here — only clients that have logged in at least once.
10
+ */
11
+ export interface ClientRecord {
12
+ clientId: string;
13
+ provider: string;
14
+ providerSubject: string;
15
+ email?: string;
16
+ createdAt: Date;
17
+ lastLoginAt?: Date;
18
+ }
19
+
20
+ /** Fields required to insert a new client. */
21
+ export interface NewClient {
22
+ clientId: string;
23
+ provider: string;
24
+ providerSubject: string;
25
+ email?: string;
26
+ }
27
+
28
+ /**
29
+ * Persistence boundary for authenticated clients (accounts).
30
+ *
31
+ * Implementations may be Postgres ({@link PgClientRepository}) or in-memory
32
+ * ({@link InMemoryClientRepository}); the rest of the service depends only on
33
+ * this interface.
34
+ */
35
+ export interface IClientRepository {
36
+ /** Find a client by its external identity. Returns undefined if none exists. */
37
+ findByProvider(provider: string, providerSubject: string): Promise<ClientRecord | undefined>;
38
+
39
+ /** Find a client by its immutable clientId. Returns undefined if none exists. */
40
+ findById(clientId: string): Promise<ClientRecord | undefined>;
41
+
42
+ /**
43
+ * Insert a new client. Rejects if `clientId` already exists or if
44
+ * `(provider, providerSubject)` is already mapped to another client.
45
+ */
46
+ create(client: NewClient): Promise<ClientRecord>;
47
+
48
+ /** Set last_login_at to now for the given client. No-op if the client is unknown. */
49
+ touchLastLogin(clientId: string): Promise<void>;
50
+ }