@fluojs/jwt 1.0.0-beta.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/LICENSE +21 -0
- package/README.ko.md +169 -0
- package/README.md +169 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/module.d.ts +13 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +93 -0
- package/dist/refresh/refresh-token.d.ts +48 -0
- package/dist/refresh/refresh-token.d.ts.map +1 -0
- package/dist/refresh/refresh-token.js +143 -0
- package/dist/service.d.ts +149 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +175 -0
- package/dist/signing/jwks.d.ts +12 -0
- package/dist/signing/jwks.d.ts.map +1 -0
- package/dist/signing/jwks.js +71 -0
- package/dist/signing/signer.d.ts +14 -0
- package/dist/signing/signer.d.ts.map +1 -0
- package/dist/signing/signer.js +124 -0
- package/dist/signing/verifier.d.ts +38 -0
- package/dist/signing/verifier.d.ts.map +1 -0
- package/dist/signing/verifier.js +319 -0
- package/dist/status.d.ts +19 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +83 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vitest.d.js +0 -0
- package/package.json +53 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { JwtConfigurationError, JwtExpiredTokenError, JwtInvalidTokenError } from '../errors.js';
|
|
3
|
+
export function normalizeRefreshTokenOptions(options) {
|
|
4
|
+
if (!options) {
|
|
5
|
+
throw new JwtConfigurationError('JWT refresh token options are not configured.');
|
|
6
|
+
}
|
|
7
|
+
if (typeof options.secret !== 'string' || options.secret.length === 0) {
|
|
8
|
+
throw new JwtConfigurationError('JWT refresh token secret must be a non-empty string.');
|
|
9
|
+
}
|
|
10
|
+
if (!Number.isFinite(options.expiresInSeconds) || options.expiresInSeconds <= 0) {
|
|
11
|
+
throw new JwtConfigurationError('JWT refresh token expiresInSeconds must be a positive finite number.');
|
|
12
|
+
}
|
|
13
|
+
if (options.verifyMaxAgeSeconds !== undefined && (!Number.isFinite(options.verifyMaxAgeSeconds) || options.verifyMaxAgeSeconds < 0)) {
|
|
14
|
+
throw new JwtConfigurationError('JWT refresh token verifyMaxAgeSeconds must be a non-negative finite number.');
|
|
15
|
+
}
|
|
16
|
+
if (options.rotation && typeof options.store.consume !== 'function') {
|
|
17
|
+
throw new JwtConfigurationError('Refresh token rotation requires an atomic store.consume() implementation.');
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
...options
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export class RefreshTokenService {
|
|
24
|
+
options;
|
|
25
|
+
constructor(options, signer, verifier) {
|
|
26
|
+
this.signer = signer;
|
|
27
|
+
this.verifier = verifier;
|
|
28
|
+
this.options = normalizeRefreshTokenOptions(options);
|
|
29
|
+
}
|
|
30
|
+
async issueRefreshToken(subject) {
|
|
31
|
+
const family = randomUUID();
|
|
32
|
+
return this.issueRefreshTokenWithFamily(subject, family);
|
|
33
|
+
}
|
|
34
|
+
async rotateRefreshToken(currentToken) {
|
|
35
|
+
const claims = await this.verifyRefreshClaims(currentToken);
|
|
36
|
+
if (this.options.rotation) {
|
|
37
|
+
if (!this.options.store.consume) {
|
|
38
|
+
throw new JwtConfigurationError('Refresh token rotation requires an atomic store.consume() implementation.');
|
|
39
|
+
}
|
|
40
|
+
const consumeResult = await this.options.store.consume({
|
|
41
|
+
family: claims.family,
|
|
42
|
+
now: new Date(),
|
|
43
|
+
subject: claims.sub,
|
|
44
|
+
tokenId: claims.jti
|
|
45
|
+
});
|
|
46
|
+
if (consumeResult === 'consumed') {
|
|
47
|
+
const refreshToken = await this.issueRefreshTokenWithFamily(claims.sub, claims.family);
|
|
48
|
+
const accessToken = await this.signer.signAccessToken({
|
|
49
|
+
sub: claims.sub
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
accessToken,
|
|
53
|
+
refreshToken
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (consumeResult === 'already_used') {
|
|
57
|
+
await this.options.store.revokeBySubject(claims.sub);
|
|
58
|
+
throw new JwtInvalidTokenError('Refresh token reuse detected.');
|
|
59
|
+
}
|
|
60
|
+
if (consumeResult === 'expired') {
|
|
61
|
+
throw new JwtExpiredTokenError('Refresh token has expired.');
|
|
62
|
+
}
|
|
63
|
+
if (consumeResult === 'not_found' || consumeResult === 'invalid') {
|
|
64
|
+
throw new JwtInvalidTokenError('Refresh token record was not found.');
|
|
65
|
+
}
|
|
66
|
+
throw new JwtInvalidTokenError('Refresh token record does not match token claims.');
|
|
67
|
+
}
|
|
68
|
+
const record = await this.options.store.find(claims.jti);
|
|
69
|
+
if (!record) {
|
|
70
|
+
throw new JwtInvalidTokenError('Refresh token record was not found.');
|
|
71
|
+
}
|
|
72
|
+
if (record.subject !== claims.sub || record.family !== claims.family) {
|
|
73
|
+
throw new JwtInvalidTokenError('Refresh token record does not match token claims.');
|
|
74
|
+
}
|
|
75
|
+
if (record.expiresAt.getTime() <= Date.now()) {
|
|
76
|
+
throw new JwtExpiredTokenError('Refresh token has expired.');
|
|
77
|
+
}
|
|
78
|
+
if (record.used) {
|
|
79
|
+
await this.options.store.revokeBySubject(record.subject);
|
|
80
|
+
throw new JwtInvalidTokenError('Refresh token reuse detected.');
|
|
81
|
+
}
|
|
82
|
+
const accessToken = await this.signer.signAccessToken({
|
|
83
|
+
sub: record.subject
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
accessToken,
|
|
87
|
+
refreshToken: currentToken
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async revokeRefreshToken(tokenId) {
|
|
91
|
+
await this.options.store.revoke(tokenId);
|
|
92
|
+
}
|
|
93
|
+
async revokeAllForSubject(subject) {
|
|
94
|
+
await this.options.store.revokeBySubject(subject);
|
|
95
|
+
}
|
|
96
|
+
async issueRefreshTokenWithFamily(subject, family) {
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
const tokenId = randomUUID();
|
|
99
|
+
const expiresAt = new Date((now + this.options.expiresInSeconds) * 1000);
|
|
100
|
+
const tokenRecord = {
|
|
101
|
+
createdAt: new Date(now * 1000),
|
|
102
|
+
expiresAt,
|
|
103
|
+
family,
|
|
104
|
+
id: tokenId,
|
|
105
|
+
subject,
|
|
106
|
+
used: false
|
|
107
|
+
};
|
|
108
|
+
const claims = {
|
|
109
|
+
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
110
|
+
family,
|
|
111
|
+
iat: now,
|
|
112
|
+
jti: tokenId,
|
|
113
|
+
sub: subject,
|
|
114
|
+
type: 'refresh'
|
|
115
|
+
};
|
|
116
|
+
const refreshToken = await this.signer.signRefreshToken(claims);
|
|
117
|
+
await this.options.store.save(tokenRecord);
|
|
118
|
+
return refreshToken;
|
|
119
|
+
}
|
|
120
|
+
async verifyRefreshClaims(token) {
|
|
121
|
+
const principal = await this.verifier.verifyRefreshToken(token);
|
|
122
|
+
const claims = principal.claims;
|
|
123
|
+
if (claims.type !== 'refresh') {
|
|
124
|
+
throw new JwtInvalidTokenError('JWT is not a refresh token.');
|
|
125
|
+
}
|
|
126
|
+
if (typeof claims.jti !== 'string' || claims.jti.length === 0) {
|
|
127
|
+
throw new JwtInvalidTokenError('Refresh token is missing jti.');
|
|
128
|
+
}
|
|
129
|
+
if (typeof claims.family !== 'string' || claims.family.length === 0) {
|
|
130
|
+
throw new JwtInvalidTokenError('Refresh token is missing family.');
|
|
131
|
+
}
|
|
132
|
+
if (typeof claims.sub !== 'string' || claims.sub.length === 0) {
|
|
133
|
+
throw new JwtInvalidTokenError('Refresh token is missing sub.');
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
...claims,
|
|
137
|
+
family: claims.family,
|
|
138
|
+
jti: claims.jti,
|
|
139
|
+
sub: claims.sub,
|
|
140
|
+
type: 'refresh'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { DefaultJwtSigner } from './signing/signer.js';
|
|
2
|
+
import type { JwtVerifierOptions } from './types.js';
|
|
3
|
+
import { DefaultJwtVerifier } from './signing/verifier.js';
|
|
4
|
+
type DurationUnit = 's' | 'm' | 'h' | 'd';
|
|
5
|
+
/**
|
|
6
|
+
* Per-call claim overrides accepted by {@link JwtService.sign}.
|
|
7
|
+
*
|
|
8
|
+
* Use these options when one token needs narrower issuer, audience, subject, or
|
|
9
|
+
* lifetime semantics than the module-level signer defaults.
|
|
10
|
+
*/
|
|
11
|
+
export interface SignOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Overrides the token `aud` claim for this signing call.
|
|
14
|
+
*
|
|
15
|
+
* Match this with the verifier-side `audience` expectation to prevent a token
|
|
16
|
+
* minted for one consumer from being accepted by another.
|
|
17
|
+
*/
|
|
18
|
+
audience?: JwtVerifierOptions['audience'];
|
|
19
|
+
/**
|
|
20
|
+
* Sets the token lifetime relative to the current clock.
|
|
21
|
+
*
|
|
22
|
+
* Accepts seconds or a short duration literal such as `"60s"`, `"15m"`,
|
|
23
|
+
* `"1h"`, or `"7d"`.
|
|
24
|
+
*/
|
|
25
|
+
expiresIn?: number | `${number}${DurationUnit}`;
|
|
26
|
+
/**
|
|
27
|
+
* Overrides the token `iss` claim for this signing call.
|
|
28
|
+
*
|
|
29
|
+
* Keep issuer values stable so downstream verifiers can reject tokens from
|
|
30
|
+
* unexpected environments or services.
|
|
31
|
+
*/
|
|
32
|
+
issuer?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Sets the `nbf` claim as a NumericDate in seconds.
|
|
35
|
+
*
|
|
36
|
+
* Tokens remain invalid until the verifier clock reaches this timestamp.
|
|
37
|
+
*/
|
|
38
|
+
notBefore?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Overrides the token `sub` claim for this signing call.
|
|
41
|
+
*/
|
|
42
|
+
subject?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Per-call verification overrides accepted by {@link JwtService.verify}.
|
|
46
|
+
*
|
|
47
|
+
* These options are merged on top of the module-level verifier policy for the
|
|
48
|
+
* current token check only.
|
|
49
|
+
*/
|
|
50
|
+
export interface VerifyOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Restricts which JWT algorithms are allowed for this verification call.
|
|
53
|
+
*/
|
|
54
|
+
algorithms?: JwtVerifierOptions['algorithms'];
|
|
55
|
+
/**
|
|
56
|
+
* Expected `aud` claim value or values.
|
|
57
|
+
*
|
|
58
|
+
* Provide this when a token should only be accepted for a specific API or
|
|
59
|
+
* client boundary.
|
|
60
|
+
*/
|
|
61
|
+
audience?: JwtVerifierOptions['audience'];
|
|
62
|
+
/**
|
|
63
|
+
* Permitted clock skew in seconds when evaluating `exp`, `nbf`, and age-based
|
|
64
|
+
* checks.
|
|
65
|
+
*/
|
|
66
|
+
clockSkewSeconds?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Expected `iss` claim value for this verification call.
|
|
69
|
+
*/
|
|
70
|
+
issuer?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Maximum acceptable token age in seconds, calculated from the `iat` claim.
|
|
73
|
+
*
|
|
74
|
+
* When set, tokens without a finite `iat` claim are rejected.
|
|
75
|
+
*/
|
|
76
|
+
maxAge?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Controls whether `exp` must be present on the token.
|
|
79
|
+
*
|
|
80
|
+
* Leave this enabled for access tokens unless the issuing system explicitly
|
|
81
|
+
* documents a different contract.
|
|
82
|
+
*/
|
|
83
|
+
requireExp?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* NestJS-style facade over Fluo's default JWT signer and verifier.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* This class keeps the low-level JWT behavior from {@link DefaultJwtSigner} and
|
|
90
|
+
* {@link DefaultJwtVerifier}, but exposes a smaller `sign` / `verify` /
|
|
91
|
+
* `decode` surface for applications migrating from similar auth service
|
|
92
|
+
* patterns.
|
|
93
|
+
*/
|
|
94
|
+
export declare class JwtService {
|
|
95
|
+
private readonly options;
|
|
96
|
+
private readonly signer;
|
|
97
|
+
private readonly verifier;
|
|
98
|
+
constructor(options: JwtVerifierOptions, signer: DefaultJwtSigner, verifier: DefaultJwtVerifier);
|
|
99
|
+
/**
|
|
100
|
+
* Signs a JWT access token from arbitrary claim payload plus optional claim overrides.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* const token = await jwtService.sign(
|
|
105
|
+
* { role: 'admin' },
|
|
106
|
+
* { audience: 'admin-ui', expiresIn: '15m', subject: 'user-123' },
|
|
107
|
+
* );
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* @param payload Base JWT claims to embed in the token payload.
|
|
111
|
+
* @param options Optional per-call overrides for `aud`, `iss`, `sub`, `nbf`, and `exp`.
|
|
112
|
+
* @returns A signed JWT string suitable for bearer-token transport.
|
|
113
|
+
* @throws {Error} When `options.expiresIn` is not a supported non-negative duration.
|
|
114
|
+
*/
|
|
115
|
+
sign(payload: object, options?: SignOptions): Promise<string>;
|
|
116
|
+
/**
|
|
117
|
+
* Verifies a JWT and returns the decoded claim bag typed as `T`.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const claims = await jwtService.verify<{ sub: string; scope?: string }>(token, {
|
|
122
|
+
* audience: 'admin-ui',
|
|
123
|
+
* issuer: 'my-api',
|
|
124
|
+
* requireExp: true,
|
|
125
|
+
* });
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @param token Compact JWT string to verify.
|
|
129
|
+
* @param options Optional per-call verifier overrides layered on top of module defaults.
|
|
130
|
+
* @returns The verified token claims cast to the requested generic type.
|
|
131
|
+
* @throws {JwtInvalidTokenError} When the token is malformed or violates issuer/audience/claim requirements.
|
|
132
|
+
* @throws {JwtExpiredTokenError} When the token is expired or exceeds `maxAge`.
|
|
133
|
+
* @throws {JwtConfigurationError} When the active verifier configuration cannot validate the token.
|
|
134
|
+
*/
|
|
135
|
+
verify<T = unknown>(token: string, options?: VerifyOptions): Promise<T>;
|
|
136
|
+
/**
|
|
137
|
+
* Decodes the JWT payload segment without verifying signature or claims.
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* Use this only for diagnostics or non-authoritative inspection. Call
|
|
141
|
+
* {@link JwtService.verify} before trusting any returned claim value.
|
|
142
|
+
*
|
|
143
|
+
* @param token Compact JWT string to inspect.
|
|
144
|
+
* @returns The decoded payload object, or `null` when the token is malformed.
|
|
145
|
+
*/
|
|
146
|
+
decode(token: string): unknown;
|
|
147
|
+
}
|
|
148
|
+
export {};
|
|
149
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAa,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAe,MAAM,uBAAuB,CAAC;AAExE,KAAK,YAAY,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAoD1C;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAC;IAC1C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,MAAM,GAAG,YAAY,EAAE,CAAC;IAChD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,UAAU,CAAC,EAAE,kBAAkB,CAAC,YAAY,CAAC,CAAC;IAC9C;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAC;IAC1C;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,qBACa,UAAU;IAEnB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE,gBAAgB,EACxB,QAAQ,EAAE,kBAAkB;IAG/C;;;;;;;;;;;;;;;OAeG;IACG,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAkBnE;;;;;;;;;;;;;;;;;;OAkBG;IACG,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,CAAC,CAAC;IAiB7E;;;;;;;;;OASG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;CAmB/B"}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
let _initClass;
|
|
2
|
+
function _applyDecs(e, t, n, r, o, i) { var a, c, u, s, f, l, p, d = Symbol.metadata || Symbol.for("Symbol.metadata"), m = Object.defineProperty, h = Object.create, y = [h(null), h(null)], v = t.length; function g(t, n, r) { return function (o, i) { n && (i = o, o = e); for (var a = 0; a < t.length; a++) i = t[a].apply(o, r ? [i] : []); return r ? i : o; }; } function b(e, t, n, r) { if ("function" != typeof e && (r || void 0 !== e)) throw new TypeError(t + " must " + (n || "be") + " a function" + (r ? "" : " or undefined")); return e; } function applyDec(e, t, n, r, o, i, u, s, f, l, p) { function d(e) { if (!p(e)) throw new TypeError("Attempted to access private element on non-instance"); } var h = [].concat(t[0]), v = t[3], w = !u, D = 1 === o, S = 3 === o, j = 4 === o, E = 2 === o; function I(t, n, r) { return function (o, i) { return n && (i = o, o = e), r && r(o), P[t].call(o, i); }; } if (!w) { var P = {}, k = [], F = S ? "get" : j || D ? "set" : "value"; if (f ? (l || D ? P = { get: _setFunctionName(function () { return v(this); }, r, "get"), set: function (e) { t[4](this, e); } } : P[F] = v, l || _setFunctionName(P[F], r, E ? "" : F)) : l || (P = Object.getOwnPropertyDescriptor(e, r)), !l && !f) { if ((c = y[+s][r]) && 7 !== (c ^ o)) throw Error("Decorating two elements with the same name (" + P[F].name + ") is not supported yet"); y[+s][r] = o < 3 ? 1 : o; } } for (var N = e, O = h.length - 1; O >= 0; O -= n ? 2 : 1) { var T = b(h[O], "A decorator", "be", !0), z = n ? h[O - 1] : void 0, A = {}, H = { kind: ["field", "accessor", "method", "getter", "setter", "class"][o], name: r, metadata: a, addInitializer: function (e, t) { if (e.v) throw new TypeError("attempted to call addInitializer after decoration was finished"); b(t, "An initializer", "be", !0), i.push(t); }.bind(null, A) }; if (w) c = T.call(z, N, H), A.v = 1, b(c, "class decorators", "return") && (N = c);else if (H.static = s, H.private = f, c = H.access = { has: f ? p.bind() : function (e) { return r in e; } }, j || (c.get = f ? E ? function (e) { return d(e), P.value; } : I("get", 0, d) : function (e) { return e[r]; }), E || S || (c.set = f ? I("set", 0, d) : function (e, t) { e[r] = t; }), N = T.call(z, D ? { get: P.get, set: P.set } : P[F], H), A.v = 1, D) { if ("object" == typeof N && N) (c = b(N.get, "accessor.get")) && (P.get = c), (c = b(N.set, "accessor.set")) && (P.set = c), (c = b(N.init, "accessor.init")) && k.unshift(c);else if (void 0 !== N) throw new TypeError("accessor decorators must return an object with get, set, or init properties or undefined"); } else b(N, (l ? "field" : "method") + " decorators", "return") && (l ? k.unshift(N) : P[F] = N); } return o < 2 && u.push(g(k, s, 1), g(i, s, 0)), l || w || (f ? D ? u.splice(-1, 0, I("get", s), I("set", s)) : u.push(E ? P[F] : b.call.bind(P[F])) : m(e, r, P)), N; } function w(e) { return m(e, d, { configurable: !0, enumerable: !0, value: a }); } return void 0 !== i && (a = i[d]), a = h(null == a ? null : a), f = [], l = function (e) { e && f.push(g(e)); }, p = function (t, r) { for (var i = 0; i < n.length; i++) { var a = n[i], c = a[1], l = 7 & c; if ((8 & c) == t && !l == r) { var p = a[2], d = !!a[3], m = 16 & c; applyDec(t ? e : e.prototype, a, m, d ? "#" + p : _toPropertyKey(p), l, l < 2 ? [] : t ? s = s || [] : u = u || [], f, !!t, d, r, t && d ? function (t) { return _checkInRHS(t) === e; } : o); } } }, p(8, 0), p(0, 0), p(8, 1), p(0, 1), l(u), l(s), c = f, v || w(e), { e: c, get c() { var n = []; return v && [w(e = applyDec(e, [t], r, e.name, 5, n)), g(n, 1)]; } }; }
|
|
3
|
+
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
|
|
4
|
+
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
|
5
|
+
function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
|
|
6
|
+
function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
|
|
7
|
+
import { Inject } from '@fluojs/core';
|
|
8
|
+
import { DefaultJwtSigner } from './signing/signer.js';
|
|
9
|
+
import { DefaultJwtVerifier, JWT_OPTIONS } from './signing/verifier.js';
|
|
10
|
+
function decodeBase64Url(value) {
|
|
11
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
12
|
+
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - normalized.length % 4);
|
|
13
|
+
return Buffer.from(normalized + padding, 'base64');
|
|
14
|
+
}
|
|
15
|
+
function parseDurationUnitToSeconds(unit) {
|
|
16
|
+
switch (unit) {
|
|
17
|
+
case 's':
|
|
18
|
+
return 1;
|
|
19
|
+
case 'm':
|
|
20
|
+
return 60;
|
|
21
|
+
case 'h':
|
|
22
|
+
return 60 * 60;
|
|
23
|
+
case 'd':
|
|
24
|
+
return 60 * 60 * 24;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function parseExpiresInSeconds(expiresIn) {
|
|
28
|
+
if (expiresIn === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (typeof expiresIn === 'number') {
|
|
32
|
+
if (!Number.isFinite(expiresIn) || expiresIn < 0) {
|
|
33
|
+
throw new Error('JwtService.sign() options.expiresIn must be a non-negative finite number.');
|
|
34
|
+
}
|
|
35
|
+
return Math.floor(expiresIn);
|
|
36
|
+
}
|
|
37
|
+
const trimmed = expiresIn.trim();
|
|
38
|
+
const match = trimmed.match(/^(\d+)([smhd])$/i);
|
|
39
|
+
if (!match) {
|
|
40
|
+
throw new Error('JwtService.sign() options.expiresIn must be a number or duration string like "60s", "15m", "1h", "7d".');
|
|
41
|
+
}
|
|
42
|
+
const value = Number.parseInt(match[1] ?? '', 10);
|
|
43
|
+
const rawUnit = match[2]?.toLowerCase();
|
|
44
|
+
if (!rawUnit || !['s', 'm', 'h', 'd'].includes(rawUnit)) {
|
|
45
|
+
throw new Error('JwtService.sign() options.expiresIn uses an unsupported duration unit.');
|
|
46
|
+
}
|
|
47
|
+
return value * parseDurationUnitToSeconds(rawUnit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-call claim overrides accepted by {@link JwtService.sign}.
|
|
52
|
+
*
|
|
53
|
+
* Use these options when one token needs narrower issuer, audience, subject, or
|
|
54
|
+
* lifetime semantics than the module-level signer defaults.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-call verification overrides accepted by {@link JwtService.verify}.
|
|
59
|
+
*
|
|
60
|
+
* These options are merged on top of the module-level verifier policy for the
|
|
61
|
+
* current token check only.
|
|
62
|
+
*/
|
|
63
|
+
let _JwtService;
|
|
64
|
+
/**
|
|
65
|
+
* NestJS-style facade over Fluo's default JWT signer and verifier.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* This class keeps the low-level JWT behavior from {@link DefaultJwtSigner} and
|
|
69
|
+
* {@link DefaultJwtVerifier}, but exposes a smaller `sign` / `verify` /
|
|
70
|
+
* `decode` surface for applications migrating from similar auth service
|
|
71
|
+
* patterns.
|
|
72
|
+
*/
|
|
73
|
+
class JwtService {
|
|
74
|
+
static {
|
|
75
|
+
[_JwtService, _initClass] = _applyDecs(this, [Inject(JWT_OPTIONS, DefaultJwtSigner, DefaultJwtVerifier)], []).c;
|
|
76
|
+
}
|
|
77
|
+
constructor(options, signer, verifier) {
|
|
78
|
+
this.options = options;
|
|
79
|
+
this.signer = signer;
|
|
80
|
+
this.verifier = verifier;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Signs a JWT access token from arbitrary claim payload plus optional claim overrides.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const token = await jwtService.sign(
|
|
89
|
+
* { role: 'admin' },
|
|
90
|
+
* { audience: 'admin-ui', expiresIn: '15m', subject: 'user-123' },
|
|
91
|
+
* );
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* @param payload Base JWT claims to embed in the token payload.
|
|
95
|
+
* @param options Optional per-call overrides for `aud`, `iss`, `sub`, `nbf`, and `exp`.
|
|
96
|
+
* @returns A signed JWT string suitable for bearer-token transport.
|
|
97
|
+
* @throws {Error} When `options.expiresIn` is not a supported non-negative duration.
|
|
98
|
+
*/
|
|
99
|
+
async sign(payload, options) {
|
|
100
|
+
const now = Math.floor(Date.now() / 1000);
|
|
101
|
+
const expiresInSeconds = parseExpiresInSeconds(options?.expiresIn);
|
|
102
|
+
const claims = {
|
|
103
|
+
...payload,
|
|
104
|
+
aud: options?.audience ?? payload.aud,
|
|
105
|
+
exp: expiresInSeconds !== undefined ? now + expiresInSeconds : payload.exp,
|
|
106
|
+
iss: options?.issuer ?? payload.iss,
|
|
107
|
+
nbf: options?.notBefore ?? payload.nbf,
|
|
108
|
+
sub: options?.subject ?? payload.sub
|
|
109
|
+
};
|
|
110
|
+
return this.signer.signAccessToken(claims);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verifies a JWT and returns the decoded claim bag typed as `T`.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* const claims = await jwtService.verify<{ sub: string; scope?: string }>(token, {
|
|
119
|
+
* audience: 'admin-ui',
|
|
120
|
+
* issuer: 'my-api',
|
|
121
|
+
* requireExp: true,
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @param token Compact JWT string to verify.
|
|
126
|
+
* @param options Optional per-call verifier overrides layered on top of module defaults.
|
|
127
|
+
* @returns The verified token claims cast to the requested generic type.
|
|
128
|
+
* @throws {JwtInvalidTokenError} When the token is malformed or violates issuer/audience/claim requirements.
|
|
129
|
+
* @throws {JwtExpiredTokenError} When the token is expired or exceeds `maxAge`.
|
|
130
|
+
* @throws {JwtConfigurationError} When the active verifier configuration cannot validate the token.
|
|
131
|
+
*/
|
|
132
|
+
async verify(token, options) {
|
|
133
|
+
const verifier = options ? new DefaultJwtVerifier({
|
|
134
|
+
...this.options,
|
|
135
|
+
algorithms: options.algorithms ?? this.options.algorithms,
|
|
136
|
+
audience: options.audience ?? this.options.audience,
|
|
137
|
+
clockSkewSeconds: options.clockSkewSeconds ?? this.options.clockSkewSeconds,
|
|
138
|
+
issuer: options.issuer ?? this.options.issuer,
|
|
139
|
+
maxAge: options.maxAge ?? this.options.maxAge,
|
|
140
|
+
requireExp: options.requireExp ?? this.options.requireExp
|
|
141
|
+
}) : this.verifier;
|
|
142
|
+
const principal = await verifier.verifyAccessToken(token);
|
|
143
|
+
return principal.claims;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Decodes the JWT payload segment without verifying signature or claims.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Use this only for diagnostics or non-authoritative inspection. Call
|
|
151
|
+
* {@link JwtService.verify} before trusting any returned claim value.
|
|
152
|
+
*
|
|
153
|
+
* @param token Compact JWT string to inspect.
|
|
154
|
+
* @returns The decoded payload object, or `null` when the token is malformed.
|
|
155
|
+
*/
|
|
156
|
+
decode(token) {
|
|
157
|
+
const segments = token.split('.');
|
|
158
|
+
if (segments.length !== 3) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const [, payloadSegment] = segments;
|
|
162
|
+
if (!payloadSegment) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(decodeBase64Url(payloadSegment).toString('utf8'));
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
static {
|
|
172
|
+
_initClass();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export { _JwtService as JwtService };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
2
|
+
export declare class JwksClient {
|
|
3
|
+
private readonly uri;
|
|
4
|
+
private readonly cacheTtl;
|
|
5
|
+
private readonly requestTimeoutMs;
|
|
6
|
+
private readonly cache;
|
|
7
|
+
constructor(uri: string, cacheTtl?: number, requestTimeoutMs?: number);
|
|
8
|
+
private isAbortError;
|
|
9
|
+
getSigningKey(kid: string): Promise<KeyObject>;
|
|
10
|
+
private fetchKeys;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=jwks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwks.d.ts","sourceRoot":"","sources":["../../src/signing/jwks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAa9D,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IALnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4D;gBAG/D,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAgB,EAC1B,gBAAgB,GAAE,MAAc;IAGnD,OAAO,CAAC,YAAY;IAId,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;YA+BtC,SAAS;CAqCxB"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createPublicKey } from 'node:crypto';
|
|
2
|
+
import { JwtConfigurationError, JwtInvalidTokenError } from '../errors.js';
|
|
3
|
+
export class JwksClient {
|
|
4
|
+
cache = new Map();
|
|
5
|
+
constructor(uri, cacheTtl = 600_000, requestTimeoutMs = 5_000) {
|
|
6
|
+
this.uri = uri;
|
|
7
|
+
this.cacheTtl = cacheTtl;
|
|
8
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
9
|
+
}
|
|
10
|
+
isAbortError(error) {
|
|
11
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
12
|
+
}
|
|
13
|
+
async getSigningKey(kid) {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const cached = this.cache.get(kid);
|
|
16
|
+
if (cached && cached.expiresAt > now) {
|
|
17
|
+
return cached.key;
|
|
18
|
+
}
|
|
19
|
+
const keys = await this.fetchKeys();
|
|
20
|
+
const jwk = keys.find(entry => entry.kid === kid);
|
|
21
|
+
if (!jwk) {
|
|
22
|
+
throw new JwtInvalidTokenError('JWT key id was not found in JWKS.');
|
|
23
|
+
}
|
|
24
|
+
let key;
|
|
25
|
+
try {
|
|
26
|
+
key = createPublicKey({
|
|
27
|
+
format: 'jwk',
|
|
28
|
+
key: jwk
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
throw new JwtConfigurationError('Unable to parse JWKS key into a public key.');
|
|
32
|
+
}
|
|
33
|
+
this.cache.set(kid, {
|
|
34
|
+
expiresAt: now + this.cacheTtl,
|
|
35
|
+
key
|
|
36
|
+
});
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
async fetchKeys() {
|
|
40
|
+
let response;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
}, this.requestTimeoutMs);
|
|
45
|
+
try {
|
|
46
|
+
response = await fetch(this.uri, {
|
|
47
|
+
signal: controller.signal
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (this.isAbortError(error)) {
|
|
51
|
+
throw new JwtConfigurationError(`JWKS fetch timed out after ${String(this.requestTimeoutMs)}ms.`);
|
|
52
|
+
}
|
|
53
|
+
throw new JwtConfigurationError(`Failed to fetch JWKS from "${this.uri}".`);
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new JwtConfigurationError(`JWKS endpoint returned HTTP ${response.status}.`);
|
|
59
|
+
}
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await response.json();
|
|
63
|
+
} catch {
|
|
64
|
+
throw new JwtConfigurationError('JWKS endpoint did not return valid JSON.');
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(body.keys)) {
|
|
67
|
+
throw new JwtConfigurationError('JWKS endpoint did not return a keys array.');
|
|
68
|
+
}
|
|
69
|
+
return body.keys;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { JwtClaims, JwtVerifierOptions } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Issues access and refresh tokens with the configured signing keys and algorithms.
|
|
4
|
+
*/
|
|
5
|
+
export declare class DefaultJwtSigner {
|
|
6
|
+
private readonly options;
|
|
7
|
+
private readonly refreshAlgorithms;
|
|
8
|
+
constructor(options: JwtVerifierOptions);
|
|
9
|
+
signAccessToken(claims: JwtClaims): Promise<string>;
|
|
10
|
+
signRefreshToken(claims: JwtClaims): Promise<string>;
|
|
11
|
+
private resolveRefreshSigningOptions;
|
|
12
|
+
private signToken;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=signer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../../src/signing/signer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAgB,SAAS,EAAe,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAyB5F;;GAEG;AACH,qBACa,gBAAgB;IAGf,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAiB;gBAEtB,OAAO,EAAE,kBAAkB;IAMlD,eAAe,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;IAInD,gBAAgB,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;IAK1D,OAAO,CAAC,4BAA4B;YAYtB,SAAS;CAkFxB"}
|