@rudderjs/passport 1.0.0 → 1.1.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 (103) hide show
  1. package/boost/guidelines.md +190 -0
  2. package/dist/Passport.d.ts +93 -0
  3. package/dist/Passport.d.ts.map +1 -1
  4. package/dist/Passport.js +147 -0
  5. package/dist/Passport.js.map +1 -1
  6. package/dist/client-secret.d.ts +12 -0
  7. package/dist/client-secret.d.ts.map +1 -0
  8. package/dist/client-secret.js +63 -0
  9. package/dist/client-secret.js.map +1 -0
  10. package/dist/commands/client.d.ts +21 -0
  11. package/dist/commands/client.d.ts.map +1 -1
  12. package/dist/commands/client.js +27 -2
  13. package/dist/commands/client.js.map +1 -1
  14. package/dist/commands/keys.d.ts +28 -4
  15. package/dist/commands/keys.d.ts.map +1 -1
  16. package/dist/commands/keys.js +34 -4
  17. package/dist/commands/keys.js.map +1 -1
  18. package/dist/commands/purge.d.ts +6 -1
  19. package/dist/commands/purge.d.ts.map +1 -1
  20. package/dist/commands/purge.js +15 -31
  21. package/dist/commands/purge.js.map +1 -1
  22. package/dist/device-code-secret.d.ts +28 -0
  23. package/dist/device-code-secret.d.ts.map +1 -0
  24. package/dist/device-code-secret.js +31 -0
  25. package/dist/device-code-secret.js.map +1 -0
  26. package/dist/grants/authorization-code.d.ts +23 -0
  27. package/dist/grants/authorization-code.d.ts.map +1 -1
  28. package/dist/grants/authorization-code.js +126 -15
  29. package/dist/grants/authorization-code.js.map +1 -1
  30. package/dist/grants/client-credentials.d.ts.map +1 -1
  31. package/dist/grants/client-credentials.js +13 -5
  32. package/dist/grants/client-credentials.js.map +1 -1
  33. package/dist/grants/device-code.d.ts +10 -1
  34. package/dist/grants/device-code.d.ts.map +1 -1
  35. package/dist/grants/device-code.js +41 -10
  36. package/dist/grants/device-code.js.map +1 -1
  37. package/dist/grants/index.d.ts +1 -1
  38. package/dist/grants/index.d.ts.map +1 -1
  39. package/dist/grants/index.js +1 -1
  40. package/dist/grants/index.js.map +1 -1
  41. package/dist/grants/issue-tokens.d.ts +9 -0
  42. package/dist/grants/issue-tokens.d.ts.map +1 -1
  43. package/dist/grants/issue-tokens.js +39 -5
  44. package/dist/grants/issue-tokens.js.map +1 -1
  45. package/dist/grants/refresh-token.d.ts.map +1 -1
  46. package/dist/grants/refresh-token.js +64 -9
  47. package/dist/grants/refresh-token.js.map +1 -1
  48. package/dist/grants/safe-compare.d.ts +19 -0
  49. package/dist/grants/safe-compare.d.ts.map +1 -0
  50. package/dist/grants/safe-compare.js +28 -0
  51. package/dist/grants/safe-compare.js.map +1 -0
  52. package/dist/index.d.ts +27 -6
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +122 -67
  55. package/dist/index.js.map +1 -1
  56. package/dist/middleware/bearer.d.ts.map +1 -1
  57. package/dist/middleware/bearer.js +36 -6
  58. package/dist/middleware/bearer.js.map +1 -1
  59. package/dist/middleware/scope.d.ts +12 -2
  60. package/dist/middleware/scope.d.ts.map +1 -1
  61. package/dist/middleware/scope.js +46 -2
  62. package/dist/middleware/scope.js.map +1 -1
  63. package/dist/models/AccessToken.d.ts +32 -0
  64. package/dist/models/AccessToken.d.ts.map +1 -1
  65. package/dist/models/AccessToken.js +63 -3
  66. package/dist/models/AccessToken.js.map +1 -1
  67. package/dist/models/AuthCode.d.ts +16 -0
  68. package/dist/models/AuthCode.d.ts.map +1 -1
  69. package/dist/models/AuthCode.js +17 -1
  70. package/dist/models/AuthCode.js.map +1 -1
  71. package/dist/models/DeviceCode.d.ts +12 -2
  72. package/dist/models/DeviceCode.d.ts.map +1 -1
  73. package/dist/models/DeviceCode.js +7 -1
  74. package/dist/models/DeviceCode.js.map +1 -1
  75. package/dist/models/OAuthClient.d.ts +4 -0
  76. package/dist/models/OAuthClient.d.ts.map +1 -1
  77. package/dist/models/OAuthClient.js +13 -1
  78. package/dist/models/OAuthClient.js.map +1 -1
  79. package/dist/models/RefreshToken.d.ts +11 -0
  80. package/dist/models/RefreshToken.d.ts.map +1 -1
  81. package/dist/models/RefreshToken.js +12 -2
  82. package/dist/models/RefreshToken.js.map +1 -1
  83. package/dist/models/helpers.d.ts +6 -0
  84. package/dist/models/helpers.d.ts.map +1 -1
  85. package/dist/models/helpers.js +15 -2
  86. package/dist/models/helpers.js.map +1 -1
  87. package/dist/opaque-token.d.ts +32 -0
  88. package/dist/opaque-token.d.ts.map +1 -0
  89. package/dist/opaque-token.js +38 -0
  90. package/dist/opaque-token.js.map +1 -0
  91. package/dist/personal-access-tokens.d.ts.map +1 -1
  92. package/dist/personal-access-tokens.js +48 -10
  93. package/dist/personal-access-tokens.js.map +1 -1
  94. package/dist/routes.d.ts +149 -0
  95. package/dist/routes.d.ts.map +1 -1
  96. package/dist/routes.js +279 -41
  97. package/dist/routes.js.map +1 -1
  98. package/dist/token.d.ts +80 -4
  99. package/dist/token.d.ts.map +1 -1
  100. package/dist/token.js +97 -13
  101. package/dist/token.js.map +1 -1
  102. package/package.json +7 -6
  103. package/schema/passport.prisma +29 -9
@@ -5,6 +5,27 @@ export interface CreateClientOpts {
5
5
  grantTypes?: string[];
6
6
  confidential?: boolean;
7
7
  }
8
+ /**
9
+ * Resolve the grant-types array for a `passport:client` invocation, given
10
+ * the parsed CLI flags. Pure — exported so the CLI handler stays a thin
11
+ * wrapper and the flag → array mapping is unit-testable.
12
+ *
13
+ * - `--device` → `['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token']`
14
+ * (RFC 8628 doesn't mandate a fixed grant list; pairing `refresh_token`
15
+ * with the device flow is the expected default — without it, the
16
+ * tokens minted by polling can't be refreshed.)
17
+ * - `--client-credentials` → `['client_credentials']`
18
+ * - default → `['authorization_code', 'refresh_token']`
19
+ *
20
+ * `--personal` is intentionally NOT a case here — personal access tokens
21
+ * don't need a CLI-created OAuth client. `passport:client` short-circuits
22
+ * before this resolver runs and prints a hint pointing at
23
+ * `HasApiTokens.createToken()` instead.
24
+ */
25
+ export declare function resolveClientGrantTypes(flags: {
26
+ isDevice?: boolean;
27
+ isM2M?: boolean;
28
+ }): string[];
8
29
  /**
9
30
  * Create an OAuth client programmatically.
10
31
  * Returns the client and the plain-text secret (if confidential).
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/commands/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAU,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAG,MAAM,EAAE,CAAA;IACtB,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC;IAClE,MAAM,EAAE,WAAW,CAAA;IACnB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB,CAAC,CAuBD"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/commands/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAU,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAG,MAAM,EAAE,CAAA;IACtB,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,EAAE,CAIhG;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC;IAClE,MAAM,EAAE,WAAW,CAAA;IACnB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB,CAAC,CAuBD"}
@@ -1,16 +1,41 @@
1
1
  import { Passport } from '../Passport.js';
2
+ import { hashClientSecret } from '../client-secret.js';
3
+ /**
4
+ * Resolve the grant-types array for a `passport:client` invocation, given
5
+ * the parsed CLI flags. Pure — exported so the CLI handler stays a thin
6
+ * wrapper and the flag → array mapping is unit-testable.
7
+ *
8
+ * - `--device` → `['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token']`
9
+ * (RFC 8628 doesn't mandate a fixed grant list; pairing `refresh_token`
10
+ * with the device flow is the expected default — without it, the
11
+ * tokens minted by polling can't be refreshed.)
12
+ * - `--client-credentials` → `['client_credentials']`
13
+ * - default → `['authorization_code', 'refresh_token']`
14
+ *
15
+ * `--personal` is intentionally NOT a case here — personal access tokens
16
+ * don't need a CLI-created OAuth client. `passport:client` short-circuits
17
+ * before this resolver runs and prints a hint pointing at
18
+ * `HasApiTokens.createToken()` instead.
19
+ */
20
+ export function resolveClientGrantTypes(flags) {
21
+ if (flags.isDevice)
22
+ return ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];
23
+ if (flags.isM2M)
24
+ return ['client_credentials'];
25
+ return ['authorization_code', 'refresh_token'];
26
+ }
2
27
  /**
3
28
  * Create an OAuth client programmatically.
4
29
  * Returns the client and the plain-text secret (if confidential).
5
30
  */
6
31
  export async function createClient(opts) {
7
- const { randomBytes, createHash } = await import('node:crypto');
32
+ const { randomBytes } = await import('node:crypto');
8
33
  const confidential = opts.confidential ?? true;
9
34
  let plainSecret = null;
10
35
  let hashedSecret = null;
11
36
  if (confidential) {
12
37
  plainSecret = randomBytes(32).toString('hex');
13
- hashedSecret = createHash('sha256').update(plainSecret).digest('hex');
38
+ hashedSecret = await hashClientSecret(plainSecret);
14
39
  }
15
40
  const ClientCls = await Passport.clientModel();
16
41
  const client = await ClientCls.create({
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/commands/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAUzC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAsB;IAIvD,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAA;IAE/D,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,IAAI,IAAI,CAAA;IAC9C,IAAI,WAAW,GAAkB,IAAI,CAAA;IACrC,IAAI,YAAY,GAAkB,IAAI,CAAA;IAEtC,IAAI,YAAY,EAAE,CAAC;QACjB,WAAW,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;QAC7C,YAAY,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IACvE,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAA;IAC9C,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC;QACpC,IAAI,EAAU,IAAI,CAAC,IAAI;QACvB,MAAM,EAAQ,YAAY;QAC1B,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACxE,UAAU,EAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,oBAAoB,CAAC,CAAC;QACvE,MAAM,EAAQ,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAChC,YAAY;KACc,CAAgB,CAAA;IAE5C,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;AACxC,CAAC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/commands/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAUtD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAA8C;IACpF,IAAI,KAAK,CAAC,QAAQ;QAAE,OAAO,CAAC,8CAA8C,EAAE,eAAe,CAAC,CAAA;IAC5F,IAAI,KAAK,CAAC,KAAK;QAAK,OAAO,CAAC,oBAAoB,CAAC,CAAA;IACjD,OAAO,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAA;AAChD,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAsB;IAIvD,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAA;IAEnD,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,IAAI,IAAI,CAAA;IAC9C,IAAI,WAAW,GAAkB,IAAI,CAAA;IACrC,IAAI,YAAY,GAAkB,IAAI,CAAA;IAEtC,IAAI,YAAY,EAAE,CAAC;QACjB,WAAW,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;QAC7C,YAAY,GAAG,MAAM,gBAAgB,CAAC,WAAW,CAAC,CAAA;IACpD,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAA;IAC9C,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC;QACpC,IAAI,EAAU,IAAI,CAAC,IAAI;QACvB,MAAM,EAAQ,YAAY;QAC1B,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACxE,UAAU,EAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,oBAAoB,CAAC,CAAC;QACvE,MAAM,EAAQ,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAChC,YAAY;KACc,CAAgB,CAAA;IAE5C,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;AACxC,CAAC"}
@@ -1,11 +1,35 @@
1
+ export interface GenerateKeysResult {
2
+ privatePath: string;
3
+ publicPath: string;
4
+ /** Backup paths if existing keys were rotated under --force; null otherwise. */
5
+ backup: {
6
+ privatePath: string;
7
+ publicPath: string;
8
+ } | null;
9
+ /**
10
+ * Path of the rolling "previous public key" written under --force. The
11
+ * verifier (Passport.verificationKeys()) picks this up automatically, so
12
+ * JWTs signed before the rotation keep verifying during their natural
13
+ * lifetime instead of all logging out at the next request. Distinct from
14
+ * the timestamped audit `backup` files — `previousPublicPath` always
15
+ * lives at `oauth-previous-public.key` and gets overwritten on the next
16
+ * rotation. Null on first generation (no prior key to retain).
17
+ */
18
+ previousPublicPath: string | null;
19
+ }
1
20
  /**
2
21
  * Generate RSA keypair for JWT signing.
3
22
  * Writes to storage/oauth-private.key and storage/oauth-public.key.
23
+ *
24
+ * With `--force`, existing keys are renamed to `*.bak.<ISO-timestamp>` before
25
+ * being replaced AND the prior public key is also copied to
26
+ * `oauth-previous-public.key`. The verifier walks both keys during the
27
+ * grace window (until the prior tokens expire naturally), so a rotation no
28
+ * longer forces an immediate global sign-out. The audit backups live
29
+ * alongside for full recovery; the previous-public file is the operational
30
+ * one that the verifier consults.
4
31
  */
5
32
  export declare function generateKeys(opts?: {
6
33
  force?: boolean;
7
- }): Promise<{
8
- privatePath: string;
9
- publicPath: string;
10
- }>;
34
+ }): Promise<GenerateKeysResult>;
11
35
  //# sourceMappingURL=keys.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/commands/keys.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,wBAAsB,YAAY,CAAC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAyBvH"}
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/commands/keys.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAG,MAAM,CAAA;IACnB,gFAAgF;IAChF,MAAM,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC1D;;;;;;;;OAQG;IACH,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;CAClC;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAAC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAgD9F"}
@@ -2,26 +2,56 @@ import { Passport } from '../Passport.js';
2
2
  /**
3
3
  * Generate RSA keypair for JWT signing.
4
4
  * Writes to storage/oauth-private.key and storage/oauth-public.key.
5
+ *
6
+ * With `--force`, existing keys are renamed to `*.bak.<ISO-timestamp>` before
7
+ * being replaced AND the prior public key is also copied to
8
+ * `oauth-previous-public.key`. The verifier walks both keys during the
9
+ * grace window (until the prior tokens expire naturally), so a rotation no
10
+ * longer forces an immediate global sign-out. The audit backups live
11
+ * alongside for full recovery; the previous-public file is the operational
12
+ * one that the verifier consults.
5
13
  */
6
14
  export async function generateKeys(opts = {}) {
7
15
  const { generateKeyPairSync } = await import('node:crypto');
8
- const { writeFile, mkdir } = await import('node:fs/promises');
16
+ const { writeFile, mkdir, rename, copyFile } = await import('node:fs/promises');
9
17
  const { existsSync } = await import('node:fs');
10
18
  const { join } = await import('node:path');
11
19
  const keyDir = join(process.cwd(), Passport.keyPath());
12
20
  const privatePath = join(keyDir, 'oauth-private.key');
13
21
  const publicPath = join(keyDir, 'oauth-public.key');
14
- if (!opts.force && existsSync(privatePath)) {
22
+ const previousPublicPath = join(keyDir, 'oauth-previous-public.key');
23
+ const privateExists = existsSync(privatePath);
24
+ const publicExists = existsSync(publicPath);
25
+ if (!opts.force && privateExists) {
15
26
  throw new Error(`Keys already exist at ${privatePath}. Use --force to overwrite.`);
16
27
  }
28
+ await mkdir(keyDir, { recursive: true });
29
+ let backup = null;
30
+ let previousPublicWritten = null;
31
+ if (opts.force && (privateExists || publicExists)) {
32
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
33
+ const privateBackup = `${privatePath}.bak.${stamp}`;
34
+ const publicBackup = `${publicPath}.bak.${stamp}`;
35
+ // Copy the public key to the rolling "previous" slot BEFORE renaming —
36
+ // the verifier loads from `oauth-previous-public.key` so JWTs signed by
37
+ // the about-to-rotate key keep verifying during their natural lifetime.
38
+ if (publicExists) {
39
+ await copyFile(publicPath, previousPublicPath);
40
+ previousPublicWritten = previousPublicPath;
41
+ }
42
+ if (privateExists)
43
+ await rename(privatePath, privateBackup);
44
+ if (publicExists)
45
+ await rename(publicPath, publicBackup);
46
+ backup = { privatePath: privateBackup, publicPath: publicBackup };
47
+ }
17
48
  const { privateKey, publicKey } = generateKeyPairSync('rsa', {
18
49
  modulusLength: 4096,
19
50
  publicKeyEncoding: { type: 'spki', format: 'pem' },
20
51
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
21
52
  });
22
- await mkdir(keyDir, { recursive: true });
23
53
  await writeFile(privatePath, privateKey, { mode: 0o600 });
24
54
  await writeFile(publicPath, publicKey, { mode: 0o644 });
25
- return { privatePath, publicPath };
55
+ return { privatePath, publicPath, backup, previousPublicPath: previousPublicWritten };
26
56
  }
27
57
  //# sourceMappingURL=keys.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"keys.js","sourceRoot":"","sources":["../../src/commands/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEzC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,OAA4B,EAAE;IAC/D,MAAM,EAAE,mBAAmB,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAA;IAC3D,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAA;IAC7D,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,CAAA;IAC9C,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,CAAA;IAE1C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAA;IACtD,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IACrD,MAAM,UAAU,GAAI,IAAI,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;IAEpD,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CAAC,yBAAyB,WAAW,6BAA6B,CAAC,CAAA;IACpF,CAAC;IAED,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,GAAG,mBAAmB,CAAC,KAAK,EAAE;QAC3D,aAAa,EAAE,IAAI;QACnB,iBAAiB,EAAG,EAAE,IAAI,EAAE,MAAM,EAAG,MAAM,EAAE,KAAK,EAAE;QACpD,kBAAkB,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE;KACrD,CAAC,CAAA;IAEF,MAAM,KAAK,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,MAAM,SAAS,CAAC,WAAW,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IACzD,MAAM,SAAS,CAAC,UAAU,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IAEvD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,CAAA;AACpC,CAAC"}
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../../src/commands/keys.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Remove expired and revoked tokens from the database.
3
- * Returns counts of purged records.
3
+ *
4
+ * Each model is purged with a single bulk `deleteAll()` against the
5
+ * QueryBuilder — one round-trip per model regardless of row count. No
6
+ * hydration, no per-row delete calls, no observers (counter-style data plane).
7
+ *
8
+ * Returns the number of rows deleted per model.
4
9
  */
5
10
  export declare function purgeTokens(): Promise<{
6
11
  accessTokens: number;
@@ -1 +1 @@
1
- {"version":3,"file":"purge.d.ts","sourceRoot":"","sources":["../../src/commands/purge.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC;IAC3C,YAAY,EAAG,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAM,MAAM,CAAA;IACrB,WAAW,EAAI,MAAM,CAAA;CACtB,CAAC,CAgDD"}
1
+ {"version":3,"file":"purge.d.ts","sourceRoot":"","sources":["../../src/commands/purge.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC;IAC3C,YAAY,EAAG,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAM,MAAM,CAAA;IACrB,WAAW,EAAI,MAAM,CAAA;CACtB,CAAC,CA2BD"}
@@ -1,7 +1,12 @@
1
1
  import { Passport } from '../Passport.js';
2
2
  /**
3
3
  * Remove expired and revoked tokens from the database.
4
- * Returns counts of purged records.
4
+ *
5
+ * Each model is purged with a single bulk `deleteAll()` against the
6
+ * QueryBuilder — one round-trip per model regardless of row count. No
7
+ * hydration, no per-row delete calls, no observers (counter-style data plane).
8
+ *
9
+ * Returns the number of rows deleted per model.
5
10
  */
6
11
  export async function purgeTokens() {
7
12
  const now = new Date();
@@ -9,41 +14,20 @@ export async function purgeTokens() {
9
14
  const RefreshTokenCls = await Passport.refreshTokenModel();
10
15
  const AuthCodeCls = await Passport.authCodeModel();
11
16
  const DeviceCodeCls = await Passport.deviceCodeModel();
12
- // Purge expired/revoked access tokens
13
- const expiredAccess = await AccessTokenCls.query()
17
+ const accessTokens = await AccessTokenCls.query()
14
18
  .where('expiresAt', '<', now)
15
19
  .orWhere('revoked', true)
16
- .get();
17
- for (const t of expiredAccess) {
18
- await AccessTokenCls.delete(t.id);
19
- }
20
- // Purge expired/revoked refresh tokens
21
- const expiredRefresh = await RefreshTokenCls.query()
20
+ .deleteAll();
21
+ const refreshTokens = await RefreshTokenCls.query()
22
22
  .where('expiresAt', '<', now)
23
23
  .orWhere('revoked', true)
24
- .get();
25
- for (const t of expiredRefresh) {
26
- await RefreshTokenCls.delete(t.id);
27
- }
28
- // Purge expired auth codes
29
- const expiredCodes = await AuthCodeCls.query()
24
+ .deleteAll();
25
+ const authCodes = await AuthCodeCls.query()
30
26
  .where('expiresAt', '<', now)
31
- .get();
32
- for (const c of expiredCodes) {
33
- await AuthCodeCls.delete(c.id);
34
- }
35
- // Purge expired device codes
36
- const expiredDevices = await DeviceCodeCls.query()
27
+ .deleteAll();
28
+ const deviceCodes = await DeviceCodeCls.query()
37
29
  .where('expiresAt', '<', now)
38
- .get();
39
- for (const d of expiredDevices) {
40
- await DeviceCodeCls.delete(d.id);
41
- }
42
- return {
43
- accessTokens: expiredAccess.length,
44
- refreshTokens: expiredRefresh.length,
45
- authCodes: expiredCodes.length,
46
- deviceCodes: expiredDevices.length,
47
- };
30
+ .deleteAll();
31
+ return { accessTokens, refreshTokens, authCodes, deviceCodes };
48
32
  }
49
33
  //# sourceMappingURL=purge.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"purge.js","sourceRoot":"","sources":["../../src/commands/purge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAMzC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAM/B,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAA;IAEtB,MAAM,cAAc,GAAI,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAA;IACnD,MAAM,eAAe,GAAG,MAAM,QAAQ,CAAC,iBAAiB,EAAE,CAAA;IAC1D,MAAM,WAAW,GAAO,MAAM,QAAQ,CAAC,aAAa,EAAE,CAAA;IACtD,MAAM,aAAa,GAAK,MAAM,QAAQ,CAAC,eAAe,EAAE,CAAA;IAExD,sCAAsC;IACtC,MAAM,aAAa,GAAG,MAAM,cAAc,CAAC,KAAK,EAAE;SAC/C,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC;SACxB,GAAG,EAAmB,CAAA;IACzB,KAAK,MAAM,CAAC,IAAI,aAAa,EAAE,CAAC;QAC9B,MAAM,cAAc,CAAC,MAAM,CAAE,CAAS,CAAC,EAAY,CAAC,CAAA;IACtD,CAAC;IAED,uCAAuC;IACvC,MAAM,cAAc,GAAG,MAAM,eAAe,CAAC,KAAK,EAAE;SACjD,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC;SACxB,GAAG,EAAoB,CAAA;IAC1B,KAAK,MAAM,CAAC,IAAI,cAAc,EAAE,CAAC;QAC/B,MAAM,eAAe,CAAC,MAAM,CAAE,CAAS,CAAC,EAAY,CAAC,CAAA;IACvD,CAAC;IAED,2BAA2B;IAC3B,MAAM,YAAY,GAAG,MAAM,WAAW,CAAC,KAAK,EAAE;SAC3C,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,GAAG,EAAgB,CAAA;IACtB,KAAK,MAAM,CAAC,IAAI,YAAY,EAAE,CAAC;QAC7B,MAAM,WAAW,CAAC,MAAM,CAAE,CAAS,CAAC,EAAY,CAAC,CAAA;IACnD,CAAC;IAED,6BAA6B;IAC7B,MAAM,cAAc,GAAG,MAAM,aAAa,CAAC,KAAK,EAAE;SAC/C,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,GAAG,EAAkB,CAAA;IACxB,KAAK,MAAM,CAAC,IAAI,cAAc,EAAE,CAAC;QAC/B,MAAM,aAAa,CAAC,MAAM,CAAE,CAAS,CAAC,EAAY,CAAC,CAAA;IACrD,CAAC;IAED,OAAO;QACL,YAAY,EAAG,aAAa,CAAC,MAAM;QACnC,aAAa,EAAE,cAAc,CAAC,MAAM;QACpC,SAAS,EAAM,YAAY,CAAC,MAAM;QAClC,WAAW,EAAI,cAAc,CAAC,MAAM;KACrC,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"purge.js","sourceRoot":"","sources":["../../src/commands/purge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEzC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAM/B,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAA;IAEtB,MAAM,cAAc,GAAI,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAA;IACnD,MAAM,eAAe,GAAG,MAAM,QAAQ,CAAC,iBAAiB,EAAE,CAAA;IAC1D,MAAM,WAAW,GAAO,MAAM,QAAQ,CAAC,aAAa,EAAE,CAAA;IACtD,MAAM,aAAa,GAAK,MAAM,QAAQ,CAAC,eAAe,EAAE,CAAA;IAExD,MAAM,YAAY,GAAG,MAAM,cAAc,CAAC,KAAK,EAAE;SAC9C,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC;SACxB,SAAS,EAAE,CAAA;IAEd,MAAM,aAAa,GAAG,MAAM,eAAe,CAAC,KAAK,EAAE;SAChD,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC;SACxB,SAAS,EAAE,CAAA;IAEd,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,KAAK,EAAE;SACxC,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,SAAS,EAAE,CAAA;IAEd,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC,KAAK,EAAE;SAC5C,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,GAAG,CAAC;SAC5B,SAAS,EAAE,CAAA;IAEd,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,WAAW,EAAE,CAAA;AAChE,CAAC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * SHA-256 hashing for device-flow secrets (`device_code` + `user_code`).
3
+ *
4
+ * Different threat model from client secrets — we don't pepper here:
5
+ *
6
+ * - `deviceCode` is `randomBytes(32).toString('hex')` (256 bits CSPRNG).
7
+ * `userCode` is 8 chars from a 32-symbol alphabet (~1.1×10^12 keyspace).
8
+ * Both are already unguessable per request.
9
+ * - The threat being mitigated is **DB read leak**: an attacker with
10
+ * `SELECT *` access on `oauth_device_codes` should not get usable codes
11
+ * that they can replay against `/oauth/token` or `/oauth/device/approve`.
12
+ * SHA-256 of the plaintext is sufficient — the attacker can't reverse it,
13
+ * and brute-force by guessing the input is no easier than guessing the
14
+ * original code without a DB leak at all.
15
+ * - Pepper would help against an offline attacker who learned a column hash
16
+ * AND could test guesses against an online endpoint. Device codes are
17
+ * TTL-limited (15 min) and the per-IP rate limit (#279 + the api-group
18
+ * default) prevents online brute force, so the pepper buys nothing.
19
+ *
20
+ * This is intentionally simpler than `client-secret.ts` (which DOES pepper
21
+ * via APP_KEY) — long-lived client secrets across multiple confidential
22
+ * clients have a different risk profile from short-lived per-flow codes.
23
+ *
24
+ * Lazy-loads `node:crypto` so the package stays importable from non-Node
25
+ * runtimes that never reach this code path.
26
+ */
27
+ export declare function hashDeviceSecret(plaintext: string): Promise<string>;
28
+ //# sourceMappingURL=device-code-secret.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-code-secret.d.ts","sourceRoot":"","sources":["../src/device-code-secret.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGzE"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * SHA-256 hashing for device-flow secrets (`device_code` + `user_code`).
3
+ *
4
+ * Different threat model from client secrets — we don't pepper here:
5
+ *
6
+ * - `deviceCode` is `randomBytes(32).toString('hex')` (256 bits CSPRNG).
7
+ * `userCode` is 8 chars from a 32-symbol alphabet (~1.1×10^12 keyspace).
8
+ * Both are already unguessable per request.
9
+ * - The threat being mitigated is **DB read leak**: an attacker with
10
+ * `SELECT *` access on `oauth_device_codes` should not get usable codes
11
+ * that they can replay against `/oauth/token` or `/oauth/device/approve`.
12
+ * SHA-256 of the plaintext is sufficient — the attacker can't reverse it,
13
+ * and brute-force by guessing the input is no easier than guessing the
14
+ * original code without a DB leak at all.
15
+ * - Pepper would help against an offline attacker who learned a column hash
16
+ * AND could test guesses against an online endpoint. Device codes are
17
+ * TTL-limited (15 min) and the per-IP rate limit (#279 + the api-group
18
+ * default) prevents online brute force, so the pepper buys nothing.
19
+ *
20
+ * This is intentionally simpler than `client-secret.ts` (which DOES pepper
21
+ * via APP_KEY) — long-lived client secrets across multiple confidential
22
+ * clients have a different risk profile from short-lived per-flow codes.
23
+ *
24
+ * Lazy-loads `node:crypto` so the package stays importable from non-Node
25
+ * runtimes that never reach this code path.
26
+ */
27
+ export async function hashDeviceSecret(plaintext) {
28
+ const { createHash } = await import('node:crypto');
29
+ return createHash('sha256').update(plaintext).digest('hex');
30
+ }
31
+ //# sourceMappingURL=device-code-secret.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-code-secret.js","sourceRoot":"","sources":["../src/device-code-secret.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,SAAiB;IACtD,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAA;IAClD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC7D,CAAC"}
@@ -46,6 +46,29 @@ export interface TokenExchangeRequest {
46
46
  * Exchange an authorization code for access + refresh tokens.
47
47
  */
48
48
  export declare function exchangeAuthCode(params: TokenExchangeRequest): Promise<IssuedTokens>;
49
+ /**
50
+ * Validate requested scopes against two gates and throw `invalid_scope` per
51
+ * RFC 6749 §3.3 if any requested scope fails either:
52
+ *
53
+ * 1. **Global registry** — declared via `Passport.tokensCan({...})`.
54
+ * Rejects scopes the operator hasn't acknowledged exist.
55
+ * 2. **Per-client allow-list** — `client.scopes`. Rejects scopes outside
56
+ * the operator-configured subset for this specific client.
57
+ *
58
+ * Each gate is **only enforced when populated**:
59
+ * - Empty global registry → no global gate (treated as "scopes not yet
60
+ * declared"). Matches Laravel Passport's "no scopes defined → permissive"
61
+ * default; existing apps that haven't called `tokensCan()` won't break.
62
+ * - Empty `client.scopes` → no per-client gate ("client may request any
63
+ * globally-known scope"). The vast majority of clients leave this empty.
64
+ *
65
+ * Used by the auth-code, device-code, and client-credentials grants. Refresh
66
+ * token already has its own narrowing logic (can only narrow vs. the original
67
+ * issuance, never widen) and skips this helper.
68
+ *
69
+ * The `*` wildcard is always allowed — same convention as `Passport.validScopes()`.
70
+ */
71
+ export declare function validateScopes(client: OAuthClient, requested: string[]): void;
49
72
  export declare class OAuthError extends Error {
50
73
  readonly error: string;
51
74
  readonly errorDescription: string;
@@ -1 +1 @@
1
- {"version":3,"file":"authorization-code.d.ts","sourceRoot":"","sources":["../../src/grants/authorization-code.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAG3D,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIlE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAO,MAAM,CAAA;IACrB,WAAW,EAAI,MAAM,CAAA;IACrB,YAAY,EAAG,MAAM,CAAA;IACrB,KAAK,EAAU,MAAM,CAAA;IACrB,KAAK,CAAC,EAAS,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAS,WAAW,CAAA;IAC1B,WAAW,EAAI,MAAM,CAAA;IACrB,MAAM,EAAS,MAAM,EAAE,CAAA;IACvB,KAAK,CAAC,EAAS,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED;;;GAGG;AACH,wBAAsB,4BAA4B,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CA0C9G;AAID;;;GAGG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAK,MAAM,CAAA;IACjB,QAAQ,EAAG,MAAM,CAAA;IACjB,MAAM,EAAK,MAAM,EAAE,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B,GAAG,OAAO,CAAC,MAAM,CAAC,CAelB;AAID,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAK,MAAM,CAAA;IACpB,IAAI,EAAU,MAAM,CAAA;IACpB,QAAQ,EAAM,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,EAAG,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,YAAY,CAAC,CA0E1F;AAID,qBAAa,UAAW,SAAQ,KAAK;aAEjB,KAAK,EAAE,MAAM;aACb,gBAAgB,EAAE,MAAM;aACxB,UAAU,EAAE,MAAM;gBAFlB,KAAK,EAAE,MAAM,EACb,gBAAgB,EAAE,MAAM,EACxB,UAAU,GAAE,MAAY;IAM1C,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;CAMjC"}
1
+ {"version":3,"file":"authorization-code.d.ts","sourceRoot":"","sources":["../../src/grants/authorization-code.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAM3D,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIlE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAO,MAAM,CAAA;IACrB,WAAW,EAAI,MAAM,CAAA;IACrB,YAAY,EAAG,MAAM,CAAA;IACrB,KAAK,EAAU,MAAM,CAAA;IACrB,KAAK,CAAC,EAAS,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAS,WAAW,CAAA;IAC1B,WAAW,EAAI,MAAM,CAAA;IACrB,MAAM,EAAS,MAAM,EAAE,CAAA;IACvB,KAAK,CAAC,EAAS,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED;;;GAGG;AACH,wBAAsB,4BAA4B,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAoD9G;AAID;;;GAGG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAK,MAAM,CAAA;IACjB,QAAQ,EAAG,MAAM,CAAA;IACjB,MAAM,EAAK,MAAM,EAAE,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B,GAAG,OAAO,CAAC,MAAM,CAAC,CAwBlB;AAID,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAK,MAAM,CAAA;IACpB,IAAI,EAAU,MAAM,CAAA;IACpB,QAAQ,EAAM,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,EAAG,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,YAAY,CAAC,CA0H1F;AAID;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI,CA0B7E;AAID,qBAAa,UAAW,SAAQ,KAAK;aAEjB,KAAK,EAAE,MAAM;aACb,gBAAgB,EAAE,MAAM;aACxB,UAAU,EAAE,MAAM;gBAFlB,KAAK,EAAE,MAAM,EACb,gBAAgB,EAAE,MAAM,EACxB,UAAU,GAAE,MAAY;IAM1C,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;CAMjC"}
@@ -1,5 +1,8 @@
1
1
  import { Passport } from '../Passport.js';
2
2
  import { clientHelpers, authCodeHelpers } from '../models/helpers.js';
3
+ import { safeCompare } from './safe-compare.js';
4
+ import { verifyClientSecret } from '../client-secret.js';
5
+ import { hashOpaqueToken, newOpaqueToken } from '../opaque-token.js';
3
6
  import { issueTokens } from './issue-tokens.js';
4
7
  /**
5
8
  * Validate an authorization request (GET /oauth/authorize).
@@ -22,15 +25,25 @@ export async function validateAuthorizationRequest(params) {
22
25
  }
23
26
  // PKCE validation
24
27
  if (params.codeChallenge) {
25
- if (params.codeChallengeMethod && params.codeChallengeMethod !== 'S256' && params.codeChallengeMethod !== 'plain') {
28
+ const method = params.codeChallengeMethod ?? 'S256';
29
+ if (method !== 'S256' && method !== 'plain') {
26
30
  throw new OAuthError('invalid_request', 'Unsupported code_challenge_method. Use S256 or plain.');
27
31
  }
32
+ // Public clients must use S256. RFC 7636 §4.4.1 + OAuth 2.0 BCP recommend
33
+ // S256 over `plain` because `plain` makes verifier == challenge — a stolen
34
+ // authorization code is already enough to mint tokens, defeating PKCE's
35
+ // entire purpose. Confidential clients keep the `plain` option for
36
+ // backward-compat with non-RFC-7636-compliant integrations.
37
+ if (method === 'plain' && clientHelpers.isPublic(client)) {
38
+ throw new OAuthError('invalid_request', 'Public clients must use code_challenge_method=S256.');
39
+ }
28
40
  }
29
41
  else if (clientHelpers.isPublic(client)) {
30
42
  // Public clients MUST use PKCE
31
43
  throw new OAuthError('invalid_request', 'Public clients must use PKCE (code_challenge required).');
32
44
  }
33
45
  const scopes = params.scope ? params.scope.split(' ').filter(Boolean) : [];
46
+ validateScopes(client, scopes);
34
47
  const result = {
35
48
  client,
36
49
  redirectUri: params.redirectUri,
@@ -52,17 +65,25 @@ export async function validateAuthorizationRequest(params) {
52
65
  */
53
66
  export async function issueAuthCode(opts) {
54
67
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
68
+ // M5/P6: the plaintext returned to the redirect URI is freshly generated
69
+ // CSPRNG hex; only its SHA-256 is persisted. The previous shape returned
70
+ // the row's cuid `id` directly, so a DB read leak handed every in-flight
71
+ // auth code to anyone with `SELECT * ON oauth_auth_codes` privilege.
72
+ const codePlaintext = await newOpaqueToken();
73
+ const codeHash = await hashOpaqueToken(codePlaintext);
55
74
  const AuthCodeCls = await Passport.authCodeModel();
56
- const code = await AuthCodeCls.create({
75
+ await AuthCodeCls.create({
57
76
  userId: opts.userId,
58
77
  clientId: opts.clientId,
78
+ tokenHash: codeHash,
59
79
  scopes: JSON.stringify(opts.scopes),
60
80
  revoked: false,
61
81
  expiresAt,
82
+ redirectUri: opts.redirectUri,
62
83
  codeChallenge: opts.codeChallenge ?? null,
63
84
  codeChallengeMethod: opts.codeChallengeMethod ?? null,
64
85
  });
65
- return code.id;
86
+ return codePlaintext;
66
87
  }
67
88
  /**
68
89
  * Exchange an authorization code for access + refresh tokens.
@@ -73,24 +94,36 @@ export async function exchangeAuthCode(params) {
73
94
  }
74
95
  const ClientCls = await Passport.clientModel();
75
96
  const AuthCodeCls = await Passport.authCodeModel();
76
- // Validate client
97
+ // Validate client. RFC 6749 §5.2 — client authentication failures at
98
+ // the token endpoint MUST return HTTP 401 with a `WWW-Authenticate`
99
+ // header (the latter is set in routes.ts on 401 responses). The
100
+ // refresh-token and client-credentials grants already return 401 here
101
+ // — auth-code was the inconsistent outlier.
77
102
  const client = await ClientCls.where('id', params.clientId).first();
78
103
  if (!client || client.revoked) {
79
- throw new OAuthError('invalid_client', 'Client not found.');
104
+ throw new OAuthError('invalid_client', 'Client not found.', 401);
80
105
  }
81
106
  // Confidential clients must provide a valid secret
82
107
  if (client.confidential) {
83
108
  if (!params.clientSecret) {
84
- throw new OAuthError('invalid_client', 'Client secret required.');
109
+ throw new OAuthError('invalid_client', 'Client secret required.', 401);
85
110
  }
86
- const { createHash } = await import('node:crypto');
87
- const hashed = createHash('sha256').update(params.clientSecret).digest('hex');
88
- if (hashed !== client.secret) {
89
- throw new OAuthError('invalid_client', 'Invalid client secret.');
111
+ // Schema allows `client.secret` to be null; explicit guard so a future
112
+ // refactor can't mask `secret = null` as authenticating. See
113
+ // `client-credentials.ts` for the longer-form rationale.
114
+ if (client.secret == null) {
115
+ throw new OAuthError('invalid_client', 'Confidential client has no secret on file.', 401);
116
+ }
117
+ if (!(await verifyClientSecret(params.clientSecret, client.secret))) {
118
+ throw new OAuthError('invalid_client', 'Invalid client secret.', 401);
90
119
  }
91
120
  }
92
- // Validate auth code
93
- const authCode = await AuthCodeCls.where('id', params.code).first();
121
+ // Validate auth code by hashed plaintext (M5/P6) — the row's `id` is no
122
+ // longer the bearer secret. Pre-migration codes won't match because their
123
+ // hashed form was never persisted; affected exchanges fall through to the
124
+ // 10-minute TTL drain window and the user simply re-clicks "Authorize".
125
+ const codeHash = await hashOpaqueToken(params.code);
126
+ const authCode = await AuthCodeCls.where('tokenHash', codeHash).first();
94
127
  if (!authCode) {
95
128
  throw new OAuthError('invalid_grant', 'Authorization code not found.');
96
129
  }
@@ -103,6 +136,20 @@ export async function exchangeAuthCode(params) {
103
136
  if (authCode.clientId !== params.clientId) {
104
137
  throw new OAuthError('invalid_grant', 'Authorization code was not issued to this client.');
105
138
  }
139
+ // RFC 6749 §4.1.3 — if a redirect_uri was bound at issuance, the exchange
140
+ // MUST present an identical value. Without this binding, an auth code
141
+ // obtained through one approved redirect can be exchanged via any other
142
+ // redirect registered to the same client, breaking the OAuth threat model.
143
+ // `redirectUri` is null only for codes issued before this column existed
144
+ // (≤10-minute legacy compat window after the migration lands).
145
+ if (authCode.redirectUri !== null && authCode.redirectUri !== undefined) {
146
+ if (!params.redirectUri) {
147
+ throw new OAuthError('invalid_grant', 'redirect_uri is required for this authorization code.');
148
+ }
149
+ if (authCode.redirectUri !== params.redirectUri) {
150
+ throw new OAuthError('invalid_grant', 'redirect_uri does not match the value used at authorization time.');
151
+ }
152
+ }
106
153
  // PKCE verification
107
154
  if (authCode.codeChallenge) {
108
155
  if (!params.codeVerifier) {
@@ -119,12 +166,33 @@ export async function exchangeAuthCode(params) {
119
166
  // plain
120
167
  expected = params.codeVerifier;
121
168
  }
122
- if (expected !== authCode.codeChallenge) {
169
+ // Constant-time compare; both sides are equal-length encodings (S256 →
170
+ // base64url SHA-256, plain → identity). On mismatch the helper short-
171
+ // circuits the length check first, but the equal-length common path
172
+ // runs the full timingSafeEqual.
173
+ if (!(await safeCompare(expected, authCode.codeChallenge))) {
123
174
  throw new OAuthError('invalid_grant', 'PKCE code_verifier does not match.');
124
175
  }
125
176
  }
126
- // Revoke the auth code (single-use)
127
- await AuthCodeCls.update(authCode.id, { revoked: true });
177
+ // Atomically consume the auth code (M3). RFC 6749 §4.1.2 requires
178
+ // single-use codes. Without a conditional update, two concurrent
179
+ // exchanges of the same code can BOTH read `revoked=false`, BOTH pass
180
+ // PKCE / redirect_uri / client checks, and BOTH issue tokens. The
181
+ // unconditional update used previously was idempotent at the SQL level,
182
+ // so the second writer didn't see any error. We use a conditional
183
+ // `where('revoked', false).updateAll(...)` instead — the underlying
184
+ // `UPDATE ... WHERE revoked = false` is atomic in every SQL backend, so
185
+ // exactly one caller observes `count === 1`; the rest see `count === 0`
186
+ // and surface `invalid_grant`. (Tokens already minted from a prior
187
+ // successful exchange of the same code are NOT retroactively revoked
188
+ // here — that's a separate hardening, RFC §4.1.2's SHOULD clause.)
189
+ const consumed = await AuthCodeCls
190
+ .where('id', authCode.id)
191
+ .where('revoked', false)
192
+ .updateAll({ revoked: true });
193
+ if (consumed === 0) {
194
+ throw new OAuthError('invalid_grant', 'Authorization code has already been used.');
195
+ }
128
196
  // Issue tokens
129
197
  return issueTokens({
130
198
  userId: authCode.userId,
@@ -133,6 +201,49 @@ export async function exchangeAuthCode(params) {
133
201
  includeRefresh: true,
134
202
  });
135
203
  }
204
+ // ─── Scope validation ─────────────────────────────────────
205
+ /**
206
+ * Validate requested scopes against two gates and throw `invalid_scope` per
207
+ * RFC 6749 §3.3 if any requested scope fails either:
208
+ *
209
+ * 1. **Global registry** — declared via `Passport.tokensCan({...})`.
210
+ * Rejects scopes the operator hasn't acknowledged exist.
211
+ * 2. **Per-client allow-list** — `client.scopes`. Rejects scopes outside
212
+ * the operator-configured subset for this specific client.
213
+ *
214
+ * Each gate is **only enforced when populated**:
215
+ * - Empty global registry → no global gate (treated as "scopes not yet
216
+ * declared"). Matches Laravel Passport's "no scopes defined → permissive"
217
+ * default; existing apps that haven't called `tokensCan()` won't break.
218
+ * - Empty `client.scopes` → no per-client gate ("client may request any
219
+ * globally-known scope"). The vast majority of clients leave this empty.
220
+ *
221
+ * Used by the auth-code, device-code, and client-credentials grants. Refresh
222
+ * token already has its own narrowing logic (can only narrow vs. the original
223
+ * issuance, never widen) and skips this helper.
224
+ *
225
+ * The `*` wildcard is always allowed — same convention as `Passport.validScopes()`.
226
+ */
227
+ export function validateScopes(client, requested) {
228
+ if (requested.length === 0)
229
+ return;
230
+ const registered = Passport.scopes();
231
+ if (registered.length > 0) {
232
+ const validIds = new Set(registered.map(s => s.id));
233
+ const unknown = requested.filter(s => s !== '*' && !validIds.has(s));
234
+ if (unknown.length > 0) {
235
+ throw new OAuthError('invalid_scope', `The requested scope is invalid, unknown, or malformed: ${unknown.join(' ')}.`);
236
+ }
237
+ }
238
+ const clientScopes = clientHelpers.getScopes(client);
239
+ if (clientScopes.length > 0) {
240
+ const allow = new Set(clientScopes);
241
+ const denied = requested.filter(s => s !== '*' && !allow.has(s));
242
+ if (denied.length > 0) {
243
+ throw new OAuthError('invalid_scope', `The requested scope is not authorized for this client: ${denied.join(' ')}.`);
244
+ }
245
+ }
246
+ }
136
247
  // ─── OAuth Error ──────────────────────────────────────────
137
248
  export class OAuthError extends Error {
138
249
  error;