@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.
- package/boost/guidelines.md +190 -0
- package/dist/Passport.d.ts +93 -0
- package/dist/Passport.d.ts.map +1 -1
- package/dist/Passport.js +147 -0
- package/dist/Passport.js.map +1 -1
- package/dist/client-secret.d.ts +12 -0
- package/dist/client-secret.d.ts.map +1 -0
- package/dist/client-secret.js +63 -0
- package/dist/client-secret.js.map +1 -0
- package/dist/commands/client.d.ts +21 -0
- package/dist/commands/client.d.ts.map +1 -1
- package/dist/commands/client.js +27 -2
- package/dist/commands/client.js.map +1 -1
- package/dist/commands/keys.d.ts +28 -4
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +34 -4
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/purge.d.ts +6 -1
- package/dist/commands/purge.d.ts.map +1 -1
- package/dist/commands/purge.js +15 -31
- package/dist/commands/purge.js.map +1 -1
- package/dist/device-code-secret.d.ts +28 -0
- package/dist/device-code-secret.d.ts.map +1 -0
- package/dist/device-code-secret.js +31 -0
- package/dist/device-code-secret.js.map +1 -0
- package/dist/grants/authorization-code.d.ts +23 -0
- package/dist/grants/authorization-code.d.ts.map +1 -1
- package/dist/grants/authorization-code.js +126 -15
- package/dist/grants/authorization-code.js.map +1 -1
- package/dist/grants/client-credentials.d.ts.map +1 -1
- package/dist/grants/client-credentials.js +13 -5
- package/dist/grants/client-credentials.js.map +1 -1
- package/dist/grants/device-code.d.ts +10 -1
- package/dist/grants/device-code.d.ts.map +1 -1
- package/dist/grants/device-code.js +41 -10
- package/dist/grants/device-code.js.map +1 -1
- package/dist/grants/index.d.ts +1 -1
- package/dist/grants/index.d.ts.map +1 -1
- package/dist/grants/index.js +1 -1
- package/dist/grants/index.js.map +1 -1
- package/dist/grants/issue-tokens.d.ts +9 -0
- package/dist/grants/issue-tokens.d.ts.map +1 -1
- package/dist/grants/issue-tokens.js +39 -5
- package/dist/grants/issue-tokens.js.map +1 -1
- package/dist/grants/refresh-token.d.ts.map +1 -1
- package/dist/grants/refresh-token.js +64 -9
- package/dist/grants/refresh-token.js.map +1 -1
- package/dist/grants/safe-compare.d.ts +19 -0
- package/dist/grants/safe-compare.d.ts.map +1 -0
- package/dist/grants/safe-compare.js +28 -0
- package/dist/grants/safe-compare.js.map +1 -0
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +122 -67
- package/dist/index.js.map +1 -1
- package/dist/middleware/bearer.d.ts.map +1 -1
- package/dist/middleware/bearer.js +36 -6
- package/dist/middleware/bearer.js.map +1 -1
- package/dist/middleware/scope.d.ts +12 -2
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +46 -2
- package/dist/middleware/scope.js.map +1 -1
- package/dist/models/AccessToken.d.ts +32 -0
- package/dist/models/AccessToken.d.ts.map +1 -1
- package/dist/models/AccessToken.js +63 -3
- package/dist/models/AccessToken.js.map +1 -1
- package/dist/models/AuthCode.d.ts +16 -0
- package/dist/models/AuthCode.d.ts.map +1 -1
- package/dist/models/AuthCode.js +17 -1
- package/dist/models/AuthCode.js.map +1 -1
- package/dist/models/DeviceCode.d.ts +12 -2
- package/dist/models/DeviceCode.d.ts.map +1 -1
- package/dist/models/DeviceCode.js +7 -1
- package/dist/models/DeviceCode.js.map +1 -1
- package/dist/models/OAuthClient.d.ts +4 -0
- package/dist/models/OAuthClient.d.ts.map +1 -1
- package/dist/models/OAuthClient.js +13 -1
- package/dist/models/OAuthClient.js.map +1 -1
- package/dist/models/RefreshToken.d.ts +11 -0
- package/dist/models/RefreshToken.d.ts.map +1 -1
- package/dist/models/RefreshToken.js +12 -2
- package/dist/models/RefreshToken.js.map +1 -1
- package/dist/models/helpers.d.ts +6 -0
- package/dist/models/helpers.d.ts.map +1 -1
- package/dist/models/helpers.js +15 -2
- package/dist/models/helpers.js.map +1 -1
- package/dist/opaque-token.d.ts +32 -0
- package/dist/opaque-token.d.ts.map +1 -0
- package/dist/opaque-token.js +38 -0
- package/dist/opaque-token.js.map +1 -0
- package/dist/personal-access-tokens.d.ts.map +1 -1
- package/dist/personal-access-tokens.js +48 -10
- package/dist/personal-access-tokens.js.map +1 -1
- package/dist/routes.d.ts +149 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +279 -41
- package/dist/routes.js.map +1 -1
- package/dist/token.d.ts +80 -4
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +97 -13
- package/dist/token.js.map +1 -1
- package/package.json +7 -6
- 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":"
|
|
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"}
|
package/dist/commands/client.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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;
|
|
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"}
|
package/dist/commands/keys.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/commands/keys.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
1
|
+
{"version":3,"file":"keys.js","sourceRoot":"","sources":["../../src/commands/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAmBzC;;;;;;;;;;;GAWG;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,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAA;IAC/E,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;IACpD,MAAM,kBAAkB,GAAG,IAAI,CAAC,MAAM,EAAE,2BAA2B,CAAC,CAAA;IAEpE,MAAM,aAAa,GAAG,UAAU,CAAC,WAAW,CAAC,CAAA;IAC7C,MAAM,YAAY,GAAI,UAAU,CAAC,UAAU,CAAC,CAAA;IAE5C,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,aAAa,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,yBAAyB,WAAW,6BAA6B,CAAC,CAAA;IACpF,CAAC;IAED,MAAM,KAAK,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAExC,IAAI,MAAM,GAAiC,IAAI,CAAA;IAC/C,IAAI,qBAAqB,GAAkB,IAAI,CAAA;IAC/C,IAAI,IAAI,CAAC,KAAK,IAAI,CAAC,aAAa,IAAI,YAAY,CAAC,EAAE,CAAC;QAClD,MAAM,KAAK,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;QAC5D,MAAM,aAAa,GAAG,GAAG,WAAW,QAAQ,KAAK,EAAE,CAAA;QACnD,MAAM,YAAY,GAAI,GAAG,UAAU,QAAQ,KAAK,EAAE,CAAA;QAClD,uEAAuE;QACvE,wEAAwE;QACxE,wEAAwE;QACxE,IAAI,YAAY,EAAE,CAAC;YACjB,MAAM,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC,CAAA;YAC9C,qBAAqB,GAAG,kBAAkB,CAAA;QAC5C,CAAC;QACD,IAAI,aAAa;YAAE,MAAM,MAAM,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA;QAC3D,IAAI,YAAY;YAAG,MAAM,MAAM,CAAC,UAAU,EAAG,YAAY,CAAC,CAAA;QAC1D,MAAM,GAAG,EAAE,WAAW,EAAE,aAAa,EAAE,UAAU,EAAE,YAAY,EAAE,CAAA;IACnE,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,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,MAAM,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,CAAA;AACvF,CAAC"}
|
package/dist/commands/purge.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remove expired and revoked tokens from the database.
|
|
3
|
-
*
|
|
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":"
|
|
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"}
|
package/dist/commands/purge.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Passport } from '../Passport.js';
|
|
2
2
|
/**
|
|
3
3
|
* Remove expired and revoked tokens from the database.
|
|
4
|
-
*
|
|
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
|
-
|
|
13
|
-
const expiredAccess = await AccessTokenCls.query()
|
|
17
|
+
const accessTokens = await AccessTokenCls.query()
|
|
14
18
|
.where('expiresAt', '<', now)
|
|
15
19
|
.orWhere('revoked', true)
|
|
16
|
-
.
|
|
17
|
-
|
|
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
|
-
.
|
|
25
|
-
|
|
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
|
-
.
|
|
32
|
-
|
|
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
|
-
.
|
|
39
|
-
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
127
|
-
|
|
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;
|