@pattern-stack/codegen 0.6.3 → 0.6.5

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 (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +2 -0
  3. package/dist/runtime/subsystems/auth/auth-oauth-state.schema.d.ts +81 -0
  4. package/dist/runtime/subsystems/auth/auth-oauth-state.schema.js +12 -0
  5. package/dist/runtime/subsystems/auth/auth-oauth-state.schema.js.map +1 -0
  6. package/dist/runtime/subsystems/auth/auth.module.d.ts +39 -24
  7. package/dist/runtime/subsystems/auth/auth.module.js +246 -13
  8. package/dist/runtime/subsystems/auth/auth.module.js.map +1 -1
  9. package/dist/runtime/subsystems/auth/auth.tokens.d.ts +15 -2
  10. package/dist/runtime/subsystems/auth/auth.tokens.js +9 -1
  11. package/dist/runtime/subsystems/auth/auth.tokens.js.map +1 -1
  12. package/dist/runtime/subsystems/auth/backends/state-store.drizzle-backend.d.ts +23 -0
  13. package/dist/runtime/subsystems/auth/backends/state-store.drizzle-backend.js +68 -0
  14. package/dist/runtime/subsystems/auth/backends/state-store.drizzle-backend.js.map +1 -0
  15. package/dist/runtime/subsystems/auth/backends/state-store.memory-backend.d.ts +21 -0
  16. package/dist/runtime/subsystems/auth/backends/state-store.memory-backend.js +51 -0
  17. package/dist/runtime/subsystems/auth/backends/state-store.memory-backend.js.map +1 -0
  18. package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +31 -0
  19. package/dist/runtime/subsystems/auth/controllers/auth.controller.js +137 -0
  20. package/dist/runtime/subsystems/auth/controllers/auth.controller.js.map +1 -0
  21. package/dist/runtime/subsystems/auth/index.d.ts +13 -4
  22. package/dist/runtime/subsystems/auth/index.js +253 -14
  23. package/dist/runtime/subsystems/auth/index.js.map +1 -1
  24. package/dist/runtime/subsystems/auth/protocols/integration-store.d.ts +36 -1
  25. package/dist/runtime/subsystems/auth/protocols/oauth-state-store.d.ts +33 -7
  26. package/dist/runtime/subsystems/auth/protocols/oauth-state-store.js +12 -0
  27. package/dist/runtime/subsystems/auth/protocols/oauth-state-store.js.map +1 -1
  28. package/dist/runtime/subsystems/auth/protocols/provider-strategy.d.ts +47 -0
  29. package/dist/runtime/subsystems/auth/protocols/provider-strategy.js +1 -0
  30. package/dist/runtime/subsystems/auth/protocols/provider-strategy.js.map +1 -0
  31. package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +24 -0
  32. package/dist/runtime/subsystems/auth/protocols/user-context.js +1 -0
  33. package/dist/runtime/subsystems/auth/protocols/user-context.js.map +1 -0
  34. package/dist/runtime/subsystems/index.d.ts +9 -4
  35. package/dist/runtime/subsystems/index.js +247 -14
  36. package/dist/runtime/subsystems/index.js.map +1 -1
  37. package/dist/src/cli/index.js +578 -143
  38. package/dist/src/cli/index.js.map +1 -1
  39. package/package.json +2 -1
  40. package/runtime/subsystems/auth/auth-oauth-state.schema.ts +30 -0
  41. package/runtime/subsystems/auth/auth.module.ts +89 -32
  42. package/runtime/subsystems/auth/auth.tokens.ts +14 -1
  43. package/runtime/subsystems/auth/backends/state-store.drizzle-backend.ts +83 -0
  44. package/runtime/subsystems/auth/backends/state-store.memory-backend.ts +76 -0
  45. package/runtime/subsystems/auth/controllers/auth.controller.ts +155 -0
  46. package/runtime/subsystems/auth/index.ts +43 -4
  47. package/runtime/subsystems/auth/protocols/integration-store.ts +37 -0
  48. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +38 -6
  49. package/runtime/subsystems/auth/protocols/provider-strategy.ts +41 -0
  50. package/runtime/subsystems/auth/protocols/user-context.ts +22 -0
  51. package/runtime/subsystems/index.ts +17 -2
  52. package/templates/entity/new/backend/modules/core/sync-source.ejs.t +2 -2
  53. package/templates/entity/new/backend/modules/core/sync-source.providers.ejs.t +1 -1
  54. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -0
  55. package/templates/entity/new/prompt.js +1 -0
  56. package/templates/subsystem/auth/app-module-hook.ejs.t +21 -0
  57. package/templates/subsystem/auth/auth-oauth-state.schema.ejs.t +35 -0
  58. package/templates/subsystem/auth/env-config.ejs.t +20 -0
  59. package/templates/subsystem/auth/prompt.js +46 -0
  60. package/templates/subsystem/auth-config/codegen-config-auth-block.ejs.t +20 -0
  61. package/templates/subsystem/auth-config/prompt.js +20 -0
  62. package/templates/subsystem/auth-integrations/app-module-hook.ejs.t +16 -0
  63. package/templates/subsystem/auth-integrations/prompt.js +23 -0
  64. package/dist/runtime/subsystems/auth/backends/oauth-state-store/in-memory.d.ts +0 -24
  65. package/dist/runtime/subsystems/auth/backends/oauth-state-store/in-memory.js +0 -24
  66. package/dist/runtime/subsystems/auth/backends/oauth-state-store/in-memory.js.map +0 -1
  67. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +0 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  }
34
34
  },
35
35
  "bin": {
36
+ "codegen": "dist/src/cli/index.js",
36
37
  "cdp": "dist/src/cli/index.js"
37
38
  },
38
39
  "files": [
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Drizzle schema for the `auth_oauth_state` table — backs the
3
+ * `DrizzleOAuthStateStore` (`state-store.drizzle-backend.ts`).
4
+ *
5
+ * One row per outstanding /connect → /callback dance. Single-use; rows are
6
+ * deleted on consume. A periodic sweep (or a `WHERE expires_at < now()`
7
+ * filter on read) clears abandoned rows.
8
+ *
9
+ * Columns:
10
+ * - `state` — opaque random token, primary key.
11
+ * - `user_id` — text (matches the consumer-defined user-id shape;
12
+ * the auth subsystem doesn't constrain this to UUID
13
+ * because some apps key users by external id).
14
+ * - `redirect` — optional post-callback redirect path.
15
+ * - `expires_at` — TTL boundary; entries past this are treated as absent.
16
+ *
17
+ * Convention: schema files live at the root of the subsystem dir
18
+ * (mirrors `cache.schema.ts`, `sync-audit.schema.ts`, `domain-events.schema.ts`).
19
+ */
20
+ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
21
+ import type { InferSelectModel } from 'drizzle-orm';
22
+
23
+ export const authOAuthState = pgTable('auth_oauth_state', {
24
+ state: text('state').primaryKey(),
25
+ userId: text('user_id').notNull(),
26
+ redirect: text('redirect'),
27
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
28
+ });
29
+
30
+ export type AuthOAuthState = InferSelectModel<typeof authOAuthState>;
@@ -1,58 +1,82 @@
1
1
  /**
2
2
  * AuthModule — DynamicModule factory for the auth subsystem.
3
3
  *
4
- * Wires the two pluggable backends the subsystem ships with:
5
- * - `ENCRYPTION_KEY` → `EnvEncryptionKey` (AES-256-GCM from env)
6
- * - `OAUTH_STATE_STORE` → `InMemoryOAuthStateStore` (dev) / custom Redis impl (prod)
4
+ * Wires the pluggable backends the subsystem ships with:
5
+ * - `ENCRYPTION_KEY` → `EnvEncryptionKey` (AES-256-GCM from env)
6
+ * - `OAUTH_STATE_STORE` → `MemoryOAuthStateStore` (dev/tests) or
7
+ * `DrizzleOAuthStateStore` (prod, requires
8
+ * DRIZZLE provider).
9
+ * - `AUTH_OPTIONS` → resolved options bag (used by AuthController
10
+ * for `redirectUriBase`).
7
11
  *
8
- * The two integration-store ports (`AUTH_INTEGRATION_READER`,
9
- * `AUTH_INTEGRATION_TOKEN_WRITER`) are deliberately **not** wired by this
10
- * module they are always consumer-specific (adapters over the app's own
11
- * integrations entity/service). Consumers provide them in the module that
12
- * owns the integrations domain, not here.
12
+ * The integration-store ports (`AUTH_INTEGRATION_READER`,
13
+ * `AUTH_INTEGRATION_TOKEN_WRITER`, `AUTH_INTEGRATION_GRANT_SINK`),
14
+ * `AUTH_USER_CONTEXT`, and `STRATEGY_REGISTRY` are deliberately **not**
15
+ * wired here they are always consumer-specific:
16
+ * - integration-store ports adapt the consumer's `integrations` storage;
17
+ * - `IUserContext` adapts the app's session/JWT scheme;
18
+ * - `STRATEGY_REGISTRY` is populated from the per-provider strategy
19
+ * classes the consumer maintains.
13
20
  *
14
- * `IAuthStrategy` implementations are also per-provider and live in the
15
- * integration module that uses them (`SalesforceModule`, `HubSpotModule`, …).
16
- * The subsystem provides the abstract base class
17
- * (`OAuth2RefreshStrategy`) — binding concrete strategies is an app concern.
21
+ * Consumers provide them in their app module (or by importing the
22
+ * `auth-integrations` starter, which binds the three integration-store
23
+ * ports off a single canonical entity).
18
24
  *
19
25
  * Usage in AppModule:
20
26
  * ```typescript
21
27
  * AuthModule.forRoot({
22
28
  * encryptionKey: 'env',
23
- * oauthStateStore: 'in-memory',
24
- * });
25
- * ```
26
- *
27
- * Or inject custom providers directly:
28
- * ```typescript
29
- * AuthModule.forRoot({
30
- * encryptionKey: { useClass: MyKmsEncryptionKey },
31
- * oauthStateStore: { useClass: RedisOAuthStateStore },
29
+ * oauthStateStore: 'memory', // or 'drizzle'
30
+ * enableController: true,
31
+ * redirectUriBase: 'http://localhost:3000',
32
32
  * });
33
33
  * ```
34
34
  *
35
35
  * `global: true` means other modules don't need to re-import AuthModule to
36
- * inject `ENCRYPTION_KEY` / `OAUTH_STATE_STORE`.
36
+ * inject the auth tokens.
37
37
  */
38
38
  import { Module, type DynamicModule, type Provider } from '@nestjs/common';
39
- import { ENCRYPTION_KEY, OAUTH_STATE_STORE } from './auth.tokens';
39
+ import {
40
+ AUTH_OPTIONS,
41
+ ENCRYPTION_KEY,
42
+ OAUTH_STATE_STORE,
43
+ } from './auth.tokens';
40
44
  import { EnvEncryptionKey } from './backends/encryption-key/env';
41
- import { InMemoryOAuthStateStore } from './backends/oauth-state-store/in-memory';
45
+ import { MemoryOAuthStateStore } from './backends/state-store.memory-backend';
46
+ import { DrizzleOAuthStateStore } from './backends/state-store.drizzle-backend';
47
+ import { AuthController } from './controllers/auth.controller';
48
+ import { DRIZZLE } from '../../constants/tokens';
49
+ import type { DrizzleClient } from '../../types/drizzle';
42
50
 
43
51
  type EncryptionKeyChoice =
44
52
  | 'env'
45
53
  | Omit<Provider, 'provide'>;
46
54
 
47
55
  type OAuthStateStoreChoice =
48
- | 'in-memory'
56
+ | 'memory'
57
+ | 'drizzle'
49
58
  | Omit<Provider, 'provide'>;
50
59
 
51
60
  export interface AuthModuleOptions {
52
61
  /** `'env'` (default) or a full provider definition (e.g. `{ useClass: MyKmsEncryptionKey }`). */
53
62
  encryptionKey?: EncryptionKeyChoice;
54
- /** `'in-memory'` (default) or a full provider definition for a Redis/DB impl. */
63
+ /**
64
+ * `'memory'` (default — tests/dev) or `'drizzle'` (prod, requires DRIZZLE
65
+ * provider) or a full provider definition for a custom impl.
66
+ */
55
67
  oauthStateStore?: OAuthStateStoreChoice;
68
+ /**
69
+ * Mount `AuthController` (`/auth/:provider/connect` + `/callback`).
70
+ * Default `false` — apps that hand-roll connect/callback (rare) or that
71
+ * use the subsystem only for the refresh path can opt out.
72
+ */
73
+ enableController?: boolean;
74
+ /**
75
+ * Public base URL of the API server. Used to construct per-provider
76
+ * callback URIs as `${redirectUriBase}/auth/:provider/callback`.
77
+ * Required when `enableController: true`.
78
+ */
79
+ redirectUriBase?: string;
56
80
  }
57
81
 
58
82
  function resolveEncryptionKeyProvider(choice: EncryptionKeyChoice): Provider {
@@ -65,8 +89,23 @@ function resolveEncryptionKeyProvider(choice: EncryptionKeyChoice): Provider {
65
89
  function resolveOAuthStateStoreProvider(
66
90
  choice: OAuthStateStoreChoice,
67
91
  ): Provider {
68
- if (choice === 'in-memory') {
69
- return { provide: OAUTH_STATE_STORE, useClass: InMemoryOAuthStateStore };
92
+ if (choice === 'memory') {
93
+ return { provide: OAUTH_STATE_STORE, useClass: MemoryOAuthStateStore };
94
+ }
95
+ if (choice === 'drizzle') {
96
+ return {
97
+ provide: OAUTH_STATE_STORE,
98
+ useFactory: (db: DrizzleClient | null) => {
99
+ if (!db) {
100
+ throw new Error(
101
+ "AuthModule.forRoot: oauthStateStore: 'drizzle' selected but DRIZZLE provider is not available. " +
102
+ 'Ensure DatabaseModule (or another provider exposing DRIZZLE) is imported before AuthModule.forRoot.',
103
+ );
104
+ }
105
+ return new DrizzleOAuthStateStore(db);
106
+ },
107
+ inject: [{ token: DRIZZLE, optional: true }],
108
+ };
70
109
  }
71
110
  return { provide: OAUTH_STATE_STORE, ...choice } as Provider;
72
111
  }
@@ -74,18 +113,36 @@ function resolveOAuthStateStoreProvider(
74
113
  @Module({})
75
114
  export class AuthModule {
76
115
  static forRoot(options: AuthModuleOptions = {}): DynamicModule {
116
+ const resolved: AuthModuleOptions = {
117
+ encryptionKey: options.encryptionKey ?? 'env',
118
+ oauthStateStore: options.oauthStateStore ?? 'memory',
119
+ enableController: options.enableController ?? false,
120
+ redirectUriBase: options.redirectUriBase,
121
+ };
122
+
123
+ if (resolved.enableController && !resolved.redirectUriBase) {
124
+ throw new Error(
125
+ 'AuthModule.forRoot: redirectUriBase is required when enableController: true',
126
+ );
127
+ }
128
+
77
129
  const encryptionKeyProvider = resolveEncryptionKeyProvider(
78
- options.encryptionKey ?? 'env',
130
+ resolved.encryptionKey!,
79
131
  );
80
132
  const oauthStateStoreProvider = resolveOAuthStateStoreProvider(
81
- options.oauthStateStore ?? 'in-memory',
133
+ resolved.oauthStateStore!,
82
134
  );
135
+ const optionsProvider: Provider = {
136
+ provide: AUTH_OPTIONS,
137
+ useValue: resolved,
138
+ };
83
139
 
84
140
  return {
85
141
  module: AuthModule,
86
142
  global: true,
87
- providers: [encryptionKeyProvider, oauthStateStoreProvider],
88
- exports: [ENCRYPTION_KEY, OAUTH_STATE_STORE],
143
+ providers: [encryptionKeyProvider, oauthStateStoreProvider, optionsProvider],
144
+ controllers: resolved.enableController ? [AuthController] : [],
145
+ exports: [ENCRYPTION_KEY, OAUTH_STATE_STORE, AUTH_OPTIONS],
89
146
  };
90
147
  }
91
148
  }
@@ -12,6 +12,9 @@
12
12
  * @Inject(OAUTH_STATE_STORE) private readonly states: IOAuthStateStore,
13
13
  * @Inject(AUTH_INTEGRATION_READER) private readonly reader: IIntegrationReader,
14
14
  * @Inject(AUTH_INTEGRATION_TOKEN_WRITER) private readonly writer: IIntegrationTokenWriter,
15
+ * @Inject(AUTH_INTEGRATION_GRANT_SINK) private readonly grants: IIntegrationGrantSink,
16
+ * @Inject(AUTH_USER_CONTEXT) private readonly userCtx: IUserContext,
17
+ * @Inject(STRATEGY_REGISTRY) private readonly registry: ProviderStrategyRegistry,
15
18
  * ) {}
16
19
  * ```
17
20
  *
@@ -19,9 +22,19 @@
19
22
  * provider-specific tokens (e.g. `SALESFORCE_AUTH_STRATEGY`,
20
23
  * `HUBSPOT_AUTH_STRATEGY`) by each integration module — this subsystem does
21
24
  * not mandate a single `AUTH_STRATEGY` token because an app typically has
22
- * many concurrent strategies, one per provider.
25
+ * many concurrent strategies, one per provider. They are dispatched through
26
+ * `STRATEGY_REGISTRY` (a `ReadonlyMap<slug, ProviderStrategy>`), populated
27
+ * by per-provider modules via a `useFactory` provider.
23
28
  */
24
29
  export const ENCRYPTION_KEY = Symbol('ENCRYPTION_KEY');
25
30
  export const OAUTH_STATE_STORE = Symbol('OAUTH_STATE_STORE');
26
31
  export const AUTH_INTEGRATION_READER = Symbol('AUTH_INTEGRATION_READER');
27
32
  export const AUTH_INTEGRATION_TOKEN_WRITER = Symbol('AUTH_INTEGRATION_TOKEN_WRITER');
33
+ export const AUTH_INTEGRATION_GRANT_SINK = Symbol('AUTH_INTEGRATION_GRANT_SINK');
34
+ export const AUTH_USER_CONTEXT = Symbol('AUTH_USER_CONTEXT');
35
+ export const STRATEGY_REGISTRY = Symbol('STRATEGY_REGISTRY');
36
+ /**
37
+ * Holds the resolved `AuthModuleOptions` (used by `AuthController` to read
38
+ * `redirectUriBase` for building per-provider callback URIs).
39
+ */
40
+ export const AUTH_OPTIONS = Symbol('AUTH_OPTIONS');
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Drizzle-backed `IOAuthStateStore`.
3
+ *
4
+ * Uses the `auth_oauth_state` table (see `auth-oauth-state.schema.ts`).
5
+ * Single-use semantics enforced via `DELETE ... RETURNING`: the consume
6
+ * path atomically deletes and returns the row, so a concurrent /callback
7
+ * with the same state cannot replay.
8
+ *
9
+ * Behaviour:
10
+ * - `generate(record)` mints a 256-bit base64url token, INSERTs the row
11
+ * with `expires_at = now() + ttlMs`.
12
+ * - `consume(state)` runs `DELETE ... WHERE state = $1 RETURNING ...`
13
+ * once. Throws `OAuthStateError('missing')` if no row was deleted
14
+ * (unknown or already consumed) and `OAuthStateError('expired')` if
15
+ * the deleted row was past its `expires_at`.
16
+ */
17
+ import { randomBytes } from 'node:crypto';
18
+ import { eq } from 'drizzle-orm';
19
+ import type { DrizzleClient } from '../../../types/drizzle';
20
+ import { authOAuthState } from '../auth-oauth-state.schema';
21
+ import {
22
+ type IOAuthStateStore,
23
+ type OAuthStateRecord,
24
+ OAuthStateError,
25
+ } from '../protocols/oauth-state-store';
26
+
27
+ export interface DrizzleOAuthStateStoreOptions {
28
+ /** TTL in ms. Default 10 minutes. */
29
+ ttlMs?: number;
30
+ /** Injectable clock for tests. Default `Date.now`. */
31
+ now?: () => number;
32
+ /** Injectable token generator for tests. Default 32-byte base64url. */
33
+ generateToken?: () => string;
34
+ }
35
+
36
+ export class DrizzleOAuthStateStore implements IOAuthStateStore {
37
+ private readonly ttlMs: number;
38
+ private readonly now: () => number;
39
+ private readonly generateToken: () => string;
40
+
41
+ constructor(
42
+ private readonly db: DrizzleClient,
43
+ opts: DrizzleOAuthStateStoreOptions = {},
44
+ ) {
45
+ this.ttlMs = opts.ttlMs ?? 10 * 60 * 1000;
46
+ this.now = opts.now ?? (() => Date.now());
47
+ this.generateToken =
48
+ opts.generateToken ?? (() => randomBytes(32).toString('base64url'));
49
+ }
50
+
51
+ async generate(record: OAuthStateRecord): Promise<string> {
52
+ const state = this.generateToken();
53
+ const expiresAt = new Date(this.now() + this.ttlMs);
54
+ await this.db.insert(authOAuthState).values({
55
+ state,
56
+ userId: record.userId,
57
+ redirect: record.redirect ?? null,
58
+ expiresAt,
59
+ });
60
+ return state;
61
+ }
62
+
63
+ async consume(state: string): Promise<OAuthStateRecord> {
64
+ const rows = await this.db
65
+ .delete(authOAuthState)
66
+ .where(eq(authOAuthState.state, state))
67
+ .returning();
68
+ const row = rows[0];
69
+ if (!row) {
70
+ throw new OAuthStateError(
71
+ `OAuth state token unknown or already consumed`,
72
+ 'missing',
73
+ );
74
+ }
75
+ if (row.expiresAt.getTime() <= this.now()) {
76
+ throw new OAuthStateError(`OAuth state token expired`, 'expired');
77
+ }
78
+ return {
79
+ userId: row.userId,
80
+ redirect: row.redirect ?? undefined,
81
+ };
82
+ }
83
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * In-memory `IOAuthStateStore` backend.
3
+ *
4
+ * Single-process store — Map<state, { record, expiresAt }>. Suitable for
5
+ * tests and single-worker dev. Production deployments select the drizzle
6
+ * backend so state survives restarts and is shared across workers.
7
+ *
8
+ * Single-use semantics:
9
+ * - `generate(record)` mints a 256-bit random token (base64url, opaque).
10
+ * - `consume(state)` deletes the entry on read. A second call with the
11
+ * same state throws `OAuthStateError('replay')`.
12
+ * - Expired entries also throw (`'expired'`); the entry is deleted as a
13
+ * side effect so a later replay still surfaces correctly.
14
+ *
15
+ * TTL defaults to 10 minutes — long enough for a user to complete the
16
+ * provider's consent screen, short enough that abandoned states age out.
17
+ */
18
+ import { randomBytes } from 'node:crypto';
19
+ import {
20
+ type IOAuthStateStore,
21
+ type OAuthStateRecord,
22
+ OAuthStateError,
23
+ } from '../protocols/oauth-state-store';
24
+
25
+ export interface MemoryOAuthStateStoreOptions {
26
+ /** TTL in ms. Default 10 minutes. */
27
+ ttlMs?: number;
28
+ /** Injectable clock for tests. Default `Date.now`. */
29
+ now?: () => number;
30
+ /** Injectable token generator for tests. Default 32-byte base64url. */
31
+ generateToken?: () => string;
32
+ }
33
+
34
+ interface Slot {
35
+ record: OAuthStateRecord;
36
+ expiresAt: number;
37
+ }
38
+
39
+ export class MemoryOAuthStateStore implements IOAuthStateStore {
40
+ private readonly store = new Map<string, Slot>();
41
+ private readonly ttlMs: number;
42
+ private readonly now: () => number;
43
+ private readonly generateToken: () => string;
44
+
45
+ constructor(opts: MemoryOAuthStateStoreOptions = {}) {
46
+ this.ttlMs = opts.ttlMs ?? 10 * 60 * 1000;
47
+ this.now = opts.now ?? (() => Date.now());
48
+ this.generateToken =
49
+ opts.generateToken ?? (() => randomBytes(32).toString('base64url'));
50
+ }
51
+
52
+ async generate(record: OAuthStateRecord): Promise<string> {
53
+ const state = this.generateToken();
54
+ this.store.set(state, {
55
+ record: { ...record },
56
+ expiresAt: this.now() + this.ttlMs,
57
+ });
58
+ return state;
59
+ }
60
+
61
+ async consume(state: string): Promise<OAuthStateRecord> {
62
+ const slot = this.store.get(state);
63
+ if (!slot) {
64
+ throw new OAuthStateError(
65
+ `OAuth state token unknown or already consumed`,
66
+ 'missing',
67
+ );
68
+ }
69
+ // Delete first so a concurrent consume can't replay.
70
+ this.store.delete(state);
71
+ if (slot.expiresAt <= this.now()) {
72
+ throw new OAuthStateError(`OAuth state token expired`, 'expired');
73
+ }
74
+ return slot.record;
75
+ }
76
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * AuthController — provider-agnostic OAuth2 connect/callback dance.
3
+ *
4
+ * Mounts two routes:
5
+ * - `GET /auth/:provider/connect?redirect=...` — generates state, builds
6
+ * the provider's authorize-url, 302-redirects the browser there.
7
+ * - `GET /auth/:provider/callback?code=...&state=...` — consumes state,
8
+ * exchanges the code for tokens, hands them to the grant sink, then
9
+ * 302-redirects to the post-connect path.
10
+ *
11
+ * Hexagonal seams:
12
+ * - `STRATEGY_REGISTRY` (ReadonlyMap<slug, ProviderStrategy>) — dispatch.
13
+ * Concrete per-provider strategies live consumer-side and contribute
14
+ * entries via a `useFactory` in the consumer's app module.
15
+ * - `AUTH_USER_CONTEXT` (IUserContext) — resolves "who is this request"
16
+ * from the consumer's session/JWT/etc.
17
+ * - `OAUTH_STATE_STORE` (IOAuthStateStore) — CSRF state minting/consume.
18
+ * - `AUTH_INTEGRATION_GRANT_SINK` (IIntegrationGrantSink) — persists the
19
+ * freshly-minted grant. Adapter lives consumer-side (e.g. the
20
+ * auth-integrations starter from #285).
21
+ *
22
+ * The controller never imports `IntegrationsService` or any other concrete
23
+ * consumer type — it goes through ports only.
24
+ */
25
+ import {
26
+ Controller,
27
+ Get,
28
+ Inject,
29
+ Param,
30
+ Query,
31
+ Req,
32
+ Res,
33
+ HttpException,
34
+ HttpStatus,
35
+ } from '@nestjs/common';
36
+ import {
37
+ AUTH_INTEGRATION_GRANT_SINK,
38
+ AUTH_OPTIONS,
39
+ AUTH_USER_CONTEXT,
40
+ OAUTH_STATE_STORE,
41
+ STRATEGY_REGISTRY,
42
+ } from '../auth.tokens';
43
+ import type { AuthModuleOptions } from '../auth.module';
44
+ import type { IOAuthStateStore } from '../protocols/oauth-state-store';
45
+ import type { IUserContext } from '../protocols/user-context';
46
+ import type {
47
+ ProviderStrategy,
48
+ ProviderStrategyRegistry,
49
+ } from '../protocols/provider-strategy';
50
+ import type { IIntegrationGrantSink } from '../protocols/integration-store';
51
+
52
+ /**
53
+ * Minimal response surface used by the controller — typed loosely so we
54
+ * don't pull a hard dep on `express` or `fastify`. Both popular HTTP
55
+ * adapters expose `redirect(status, url)`.
56
+ */
57
+ interface RedirectingResponse {
58
+ redirect(statusCode: number, url: string): unknown;
59
+ }
60
+
61
+ @Controller('auth')
62
+ export class AuthController {
63
+ constructor(
64
+ @Inject(STRATEGY_REGISTRY)
65
+ private readonly registry: ProviderStrategyRegistry,
66
+ @Inject(AUTH_USER_CONTEXT)
67
+ private readonly userContext: IUserContext,
68
+ @Inject(OAUTH_STATE_STORE)
69
+ private readonly stateStore: IOAuthStateStore,
70
+ @Inject(AUTH_INTEGRATION_GRANT_SINK)
71
+ private readonly grantSink: IIntegrationGrantSink,
72
+ @Inject(AUTH_OPTIONS)
73
+ private readonly options: AuthModuleOptions,
74
+ ) {}
75
+
76
+ @Get(':provider/connect')
77
+ async connect(
78
+ @Param('provider') slug: string,
79
+ @Query('redirect') redirect: string | undefined,
80
+ @Req() req: unknown,
81
+ @Res() res: RedirectingResponse,
82
+ ): Promise<unknown> {
83
+ const strategy = this.requireStrategy(slug);
84
+ const userId = await this.userContext.getCurrentUserId(req);
85
+ const state = await this.stateStore.generate({ userId, redirect });
86
+ const url = strategy.buildAuthorizeUrl({
87
+ state,
88
+ redirectUri: this.redirectUriFor(slug),
89
+ });
90
+ return res.redirect(HttpStatus.FOUND, url);
91
+ }
92
+
93
+ @Get(':provider/callback')
94
+ async callback(
95
+ @Param('provider') slug: string,
96
+ @Query('code') code: string | undefined,
97
+ @Query('state') state: string | undefined,
98
+ @Res() res: RedirectingResponse,
99
+ ): Promise<unknown> {
100
+ const strategy = this.requireStrategy(slug);
101
+ if (!code) {
102
+ throw new HttpException(
103
+ `Missing 'code' query param`,
104
+ HttpStatus.BAD_REQUEST,
105
+ );
106
+ }
107
+ if (!state) {
108
+ throw new HttpException(
109
+ `Missing 'state' query param`,
110
+ HttpStatus.BAD_REQUEST,
111
+ );
112
+ }
113
+ const { userId, redirect } = await this.stateStore.consume(state);
114
+ const tokens = await strategy.exchangeCodeForTokens({
115
+ code,
116
+ redirectUri: this.redirectUriFor(slug),
117
+ });
118
+ await this.grantSink.createOrUpdateFromOAuthGrant({
119
+ userId,
120
+ provider: slug,
121
+ accessToken: tokens.accessToken,
122
+ refreshToken: tokens.refreshToken,
123
+ expiresAt: tokens.expiresAt,
124
+ scope: tokens.scope,
125
+ externalAccountId: tokens.externalAccountId,
126
+ providerMetadata: tokens.providerMetadata,
127
+ });
128
+ return res.redirect(
129
+ HttpStatus.FOUND,
130
+ redirect ?? `/settings/integrations?connected=${encodeURIComponent(slug)}`,
131
+ );
132
+ }
133
+
134
+ private requireStrategy(slug: string): ProviderStrategy {
135
+ const strategy = this.registry.get(slug);
136
+ if (!strategy) {
137
+ throw new HttpException(
138
+ `Unknown provider '${slug}'`,
139
+ HttpStatus.NOT_FOUND,
140
+ );
141
+ }
142
+ return strategy;
143
+ }
144
+
145
+ private redirectUriFor(slug: string): string {
146
+ const base = this.options.redirectUriBase;
147
+ if (!base) {
148
+ throw new Error(
149
+ `AuthModule.forRoot: redirectUriBase is required when AuthController is enabled`,
150
+ );
151
+ }
152
+ const trimmed = base.replace(/\/+$/, '');
153
+ return `${trimmed}/auth/${encodeURIComponent(slug)}/callback`;
154
+ }
155
+ }
@@ -6,19 +6,32 @@
6
6
  * ```typescript
7
7
  * import {
8
8
  * AuthModule,
9
+ * AuthController,
9
10
  * ENCRYPTION_KEY,
10
11
  * OAUTH_STATE_STORE,
11
12
  * AUTH_INTEGRATION_READER,
12
13
  * AUTH_INTEGRATION_TOKEN_WRITER,
14
+ * AUTH_INTEGRATION_GRANT_SINK,
15
+ * AUTH_USER_CONTEXT,
16
+ * STRATEGY_REGISTRY,
17
+ * AUTH_OPTIONS,
13
18
  * OAuth2RefreshStrategy,
14
19
  * withAuthRetry,
15
20
  * IntegrationBrokenError,
16
21
  * SessionExpiredError,
22
+ * OAuthStateError,
17
23
  * type IAuthStrategy,
18
24
  * type IEncryptionKey,
19
25
  * type IOAuthStateStore,
26
+ * type OAuthStateRecord,
20
27
  * type IIntegrationReader,
21
28
  * type IIntegrationTokenWriter,
29
+ * type IIntegrationGrantSink,
30
+ * type IntegrationGrantInput,
31
+ * type IUserContext,
32
+ * type ProviderStrategy,
33
+ * type ProviderStrategyRegistry,
34
+ * type ExchangedTokens,
22
35
  * } from '@pattern-stack/codegen/runtime/subsystems/auth';
23
36
  * ```
24
37
  */
@@ -32,14 +45,23 @@ export type {
32
45
  export type { IEncryptionKey } from './protocols/encryption-key';
33
46
  export type {
34
47
  IOAuthStateStore,
35
- OAuthStateEntry,
48
+ OAuthStateRecord,
36
49
  } from './protocols/oauth-state-store';
50
+ export { OAuthStateError } from './protocols/oauth-state-store';
37
51
  export type {
38
52
  DecryptedIntegration,
39
53
  IIntegrationReader,
40
54
  IIntegrationTokenWriter,
41
55
  IntegrationTokenUpdate,
56
+ IIntegrationGrantSink,
57
+ IntegrationGrantInput,
42
58
  } from './protocols/integration-store';
59
+ export type { IUserContext } from './protocols/user-context';
60
+ export type {
61
+ ProviderStrategy,
62
+ ProviderStrategyRegistry,
63
+ ExchangedTokens,
64
+ } from './protocols/provider-strategy';
43
65
 
44
66
  // Tokens
45
67
  export {
@@ -47,6 +69,10 @@ export {
47
69
  OAUTH_STATE_STORE,
48
70
  AUTH_INTEGRATION_READER,
49
71
  AUTH_INTEGRATION_TOKEN_WRITER,
72
+ AUTH_INTEGRATION_GRANT_SINK,
73
+ AUTH_USER_CONTEXT,
74
+ STRATEGY_REGISTRY,
75
+ AUTH_OPTIONS,
50
76
  } from './auth.tokens';
51
77
 
52
78
  // Runtime
@@ -63,15 +89,28 @@ export {
63
89
  isSessionExpiredError,
64
90
  } from './runtime/session-expired.error';
65
91
 
92
+ // Schema (drizzle backend)
93
+ export {
94
+ authOAuthState,
95
+ type AuthOAuthState,
96
+ } from './auth-oauth-state.schema';
97
+
66
98
  // Backends
67
99
  export {
68
100
  EnvEncryptionKey,
69
101
  type EnvEncryptionKeyOptions,
70
102
  } from './backends/encryption-key/env';
71
103
  export {
72
- InMemoryOAuthStateStore,
73
- type InMemoryOAuthStateStoreOptions,
74
- } from './backends/oauth-state-store/in-memory';
104
+ MemoryOAuthStateStore,
105
+ type MemoryOAuthStateStoreOptions,
106
+ } from './backends/state-store.memory-backend';
107
+ export {
108
+ DrizzleOAuthStateStore,
109
+ type DrizzleOAuthStateStoreOptions,
110
+ } from './backends/state-store.drizzle-backend';
111
+
112
+ // Controller
113
+ export { AuthController } from './controllers/auth.controller';
75
114
 
76
115
  // Module
77
116
  export { AuthModule, type AuthModuleOptions } from './auth.module';