@atcute/xrpc-server 0.1.12 → 2.0.0
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/README.md +81 -20
- package/dist/auth/jwt-creator.d.ts +4 -2
- package/dist/auth/jwt-creator.d.ts.map +1 -1
- package/dist/auth/jwt-creator.js +1 -1
- package/dist/auth/jwt-creator.js.map +1 -1
- package/dist/auth/jwt-verifier.d.ts +69 -8
- package/dist/auth/jwt-verifier.d.ts.map +1 -1
- package/dist/auth/jwt-verifier.js +130 -19
- package/dist/auth/jwt-verifier.js.map +1 -1
- package/dist/auth/jwt.d.ts +7 -2
- package/dist/auth/jwt.d.ts.map +1 -1
- package/dist/auth/jwt.js +25 -25
- package/dist/auth/jwt.js.map +1 -1
- package/dist/main/router.d.ts +32 -7
- package/dist/main/router.d.ts.map +1 -1
- package/dist/main/router.js +63 -21
- package/dist/main/router.js.map +1 -1
- package/dist/main/types/operation.d.ts +8 -0
- package/dist/main/types/operation.d.ts.map +1 -1
- package/dist/main/types/websocket.d.ts +8 -0
- package/dist/main/types/websocket.d.ts.map +1 -1
- package/dist/main/utils/websocket-mock.d.ts.map +1 -1
- package/dist/main/utils/websocket-mock.js +3 -0
- package/dist/main/utils/websocket-mock.js.map +1 -1
- package/dist/main/xrpc-error.d.ts +55 -15
- package/dist/main/xrpc-error.d.ts.map +1 -1
- package/dist/main/xrpc-error.js +66 -26
- package/dist/main/xrpc-error.js.map +1 -1
- package/lib/auth/jwt-creator.ts +5 -3
- package/lib/auth/jwt-verifier.ts +205 -25
- package/lib/auth/jwt.ts +42 -29
- package/lib/main/router.ts +95 -42
- package/lib/main/types/operation.ts +8 -0
- package/lib/main/types/websocket.ts +8 -0
- package/lib/main/utils/websocket-mock.ts +3 -0
- package/lib/main/xrpc-error.ts +107 -44
- package/package.json +20 -16
package/dist/main/xrpc-error.js
CHANGED
|
@@ -1,72 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formats one or more WWW-Authenticate challenges into a single header value.
|
|
3
|
+
*
|
|
4
|
+
* each challenge is emitted as `<scheme>` followed by its params or token68.
|
|
5
|
+
* multiple challenges are joined with `, `. auth-param values are quoted using
|
|
6
|
+
* `JSON.stringify` (RFC 7230 quoted-string semantics for ASCII content).
|
|
7
|
+
*
|
|
8
|
+
* @param challenges one challenge, or an ordered array of challenges
|
|
9
|
+
* @returns the formatted header value
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* formatWWWAuthenticate({ scheme: 'Bearer', params: { error: 'BadJwtSignature' } })
|
|
14
|
+
* // => `Bearer error="BadJwtSignature"`
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export const formatWWWAuthenticate = (challenges) => {
|
|
18
|
+
const list = Array.isArray(challenges) ? challenges : [challenges];
|
|
19
|
+
return list.map(formatChallenge).join(', ');
|
|
20
|
+
};
|
|
21
|
+
const formatChallenge = (challenge) => {
|
|
22
|
+
if (challenge.token68 !== undefined) {
|
|
23
|
+
return `${challenge.scheme} ${challenge.token68}`;
|
|
24
|
+
}
|
|
25
|
+
if (challenge.params !== undefined) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (const name in challenge.params) {
|
|
28
|
+
const value = challenge.params[name];
|
|
29
|
+
if (value !== undefined) {
|
|
30
|
+
parts.push(`${name}=${JSON.stringify(value)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (parts.length > 0) {
|
|
34
|
+
return `${challenge.scheme} ${parts.join(', ')}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return challenge.scheme;
|
|
38
|
+
};
|
|
1
39
|
export class XRPCError extends Error {
|
|
2
40
|
/** response status */
|
|
3
41
|
status;
|
|
4
42
|
/** error name */
|
|
5
43
|
error;
|
|
6
|
-
/** error message */
|
|
7
|
-
description;
|
|
8
44
|
/** response headers */
|
|
9
45
|
headers;
|
|
10
|
-
constructor({ status, error,
|
|
11
|
-
super(
|
|
46
|
+
constructor({ status, error, message, headers }) {
|
|
47
|
+
super(message);
|
|
12
48
|
this.status = status;
|
|
13
49
|
this.error = error;
|
|
14
|
-
this.description = description;
|
|
15
50
|
this.headers = headers;
|
|
16
51
|
}
|
|
17
52
|
toResponse() {
|
|
18
|
-
return Response.json({ error: this.error, message: this.
|
|
53
|
+
return Response.json({ error: this.error, message: this.message || undefined }, { status: this.status, headers: this.headers });
|
|
19
54
|
}
|
|
20
55
|
}
|
|
21
56
|
export class InvalidRequestError extends XRPCError {
|
|
22
|
-
constructor({ status = 400, error = 'InvalidRequest',
|
|
23
|
-
super({ status, error,
|
|
57
|
+
constructor({ status = 400, error = 'InvalidRequest', message, headers } = {}) {
|
|
58
|
+
super({ status, error, message, headers });
|
|
24
59
|
}
|
|
25
60
|
}
|
|
26
61
|
export class AuthRequiredError extends XRPCError {
|
|
27
|
-
constructor({ status = 401, error = 'AuthenticationRequired',
|
|
28
|
-
|
|
62
|
+
constructor({ status = 401, error = 'AuthenticationRequired', message, headers, wwwAuthenticate, } = {}) {
|
|
63
|
+
let mergedHeaders = headers;
|
|
64
|
+
if (wwwAuthenticate !== undefined) {
|
|
65
|
+
const target = new Headers(headers);
|
|
66
|
+
target.set('www-authenticate', formatWWWAuthenticate(wwwAuthenticate));
|
|
67
|
+
target.append('access-control-expose-headers', 'www-authenticate');
|
|
68
|
+
mergedHeaders = target;
|
|
69
|
+
}
|
|
70
|
+
super({ status, error, message, headers: mergedHeaders });
|
|
29
71
|
}
|
|
30
72
|
}
|
|
31
73
|
export class ForbiddenError extends XRPCError {
|
|
32
|
-
constructor({ status = 403, error = 'Forbidden',
|
|
33
|
-
super({ status, error,
|
|
74
|
+
constructor({ status = 403, error = 'Forbidden', message, headers } = {}) {
|
|
75
|
+
super({ status, error, message, headers });
|
|
34
76
|
}
|
|
35
77
|
}
|
|
36
78
|
export class RateLimitExceededError extends XRPCError {
|
|
37
|
-
constructor({ status = 429, error = 'RateLimitExceeded',
|
|
38
|
-
super({ status, error,
|
|
79
|
+
constructor({ status = 429, error = 'RateLimitExceeded', message, headers, } = {}) {
|
|
80
|
+
super({ status, error, message, headers });
|
|
39
81
|
}
|
|
40
82
|
}
|
|
41
83
|
export class InternalServerError extends XRPCError {
|
|
42
|
-
constructor({ status = 500, error = 'InternalServerError',
|
|
43
|
-
super({ status, error,
|
|
84
|
+
constructor({ status = 500, error = 'InternalServerError', message, headers, } = {}) {
|
|
85
|
+
super({ status, error, message, headers });
|
|
44
86
|
}
|
|
45
87
|
}
|
|
46
88
|
export class UpstreamFailureError extends XRPCError {
|
|
47
|
-
constructor({ status = 502, error = 'UpstreamFailure',
|
|
48
|
-
super({ status, error,
|
|
89
|
+
constructor({ status = 502, error = 'UpstreamFailure', message, headers } = {}) {
|
|
90
|
+
super({ status, error, message, headers });
|
|
49
91
|
}
|
|
50
92
|
}
|
|
51
93
|
export class NotEnoughResourcesError extends XRPCError {
|
|
52
|
-
constructor({ status = 503, error = 'NotEnoughResources',
|
|
53
|
-
super({ status, error,
|
|
94
|
+
constructor({ status = 503, error = 'NotEnoughResources', message, headers, } = {}) {
|
|
95
|
+
super({ status, error, message, headers });
|
|
54
96
|
}
|
|
55
97
|
}
|
|
56
98
|
export class UpstreamTimeoutError extends XRPCError {
|
|
57
|
-
constructor({ status = 504, error = 'UpstreamTimeout',
|
|
58
|
-
super({ status, error,
|
|
99
|
+
constructor({ status = 504, error = 'UpstreamTimeout', message, headers } = {}) {
|
|
100
|
+
super({ status, error, message, headers });
|
|
59
101
|
}
|
|
60
102
|
}
|
|
61
103
|
export class XRPCSubscriptionError extends Error {
|
|
62
104
|
closeCode;
|
|
63
105
|
error;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
super(`Subscription error: ${error}${description ? ` - ${description}` : ''}`);
|
|
106
|
+
constructor({ closeCode = 1008, error, message }) {
|
|
107
|
+
super(message);
|
|
67
108
|
this.closeCode = closeCode;
|
|
68
109
|
this.error = error;
|
|
69
|
-
this.description = description;
|
|
70
110
|
}
|
|
71
111
|
}
|
|
72
112
|
//# sourceMappingURL=xrpc-error.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xrpc-error.js","sourceRoot":"","sources":["../../lib/main/xrpc-error.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"xrpc-error.js","sourceRoot":"","sources":["../../lib/main/xrpc-error.ts"],"names":[],"mappings":"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"}
|
package/lib/auth/jwt-creator.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PrivateKey } from '@atcute/crypto';
|
|
2
2
|
import type { Did, Nsid } from '@atcute/lexicons';
|
|
3
|
+
import type { AtprotoAudience } from '@atcute/lexicons/syntax';
|
|
3
4
|
import { toBase64Url } from '@atcute/multibase';
|
|
4
5
|
import { encodeUtf8 } from '@atcute/uint8array';
|
|
5
6
|
|
|
@@ -10,8 +11,9 @@ import type { JwtHeader, JwtPayload } from './jwt.ts';
|
|
|
10
11
|
export interface CreateServiceJwtOptions {
|
|
11
12
|
keypair: PrivateKey;
|
|
12
13
|
issuer: Did;
|
|
13
|
-
audience:
|
|
14
|
-
|
|
14
|
+
/** audience is either a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */
|
|
15
|
+
audience: Did | AtprotoAudience;
|
|
16
|
+
lxm: Nsid;
|
|
15
17
|
issuedAt?: number;
|
|
16
18
|
expiresIn?: number;
|
|
17
19
|
}
|
|
@@ -33,7 +35,7 @@ export const createServiceJwt = async (options: CreateServiceJwtOptions): Promis
|
|
|
33
35
|
iat: issuedAt,
|
|
34
36
|
iss: options.issuer,
|
|
35
37
|
jti: nanoid(24),
|
|
36
|
-
lxm: options.lxm
|
|
38
|
+
lxm: options.lxm,
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
const headerB64 = encodeJwtPortion(header);
|
package/lib/auth/jwt-verifier.ts
CHANGED
|
@@ -1,61 +1,161 @@
|
|
|
1
1
|
import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { getVerificationMaterial, type DidDocument } from '@atcute/identity';
|
|
3
3
|
import { type DidDocumentResolver } from '@atcute/identity-resolver';
|
|
4
4
|
import type { Did, Nsid } from '@atcute/lexicons';
|
|
5
|
+
import type { AtprotoAudience } from '@atcute/lexicons/syntax';
|
|
5
6
|
import * as uint8arrays from '@atcute/uint8array';
|
|
6
7
|
|
|
8
|
+
import { AuthRequiredError } from '../main/xrpc-error.ts';
|
|
7
9
|
import type { Result } from '../types/misc.ts';
|
|
8
10
|
|
|
9
11
|
import { parseJwt, type ParsedJwt } from './jwt.ts';
|
|
10
12
|
import type { AuthError } from './types.ts';
|
|
11
13
|
|
|
14
|
+
type SupportedKid = `#${string}`;
|
|
15
|
+
/** only `#atproto` is accepted as a signing key identifier for now */
|
|
16
|
+
const DEFAULT_KID: SupportedKid = '#atproto';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* replay-protection store for service JWTs. when configured on a verifier,
|
|
20
|
+
* tokens must carry a `jti` claim and the verifier consults this store to
|
|
21
|
+
* reject duplicates.
|
|
22
|
+
*/
|
|
23
|
+
export interface ReplayStore {
|
|
24
|
+
/**
|
|
25
|
+
* record a `(iss, jti)` pair seen now.
|
|
26
|
+
*
|
|
27
|
+
* @param key issuer + token identifier; implementations decide how to
|
|
28
|
+
* encode this into a storage key.
|
|
29
|
+
* @param ttlSeconds how long the entry must be retained. implementations
|
|
30
|
+
* are free to retain it for longer.
|
|
31
|
+
* @returns `true` if the pair was previously unseen (token is unique),
|
|
32
|
+
* `false` if the pair has been recorded before (replay).
|
|
33
|
+
*/
|
|
34
|
+
check(key: { iss: Did; jti: string }, ttlSeconds: number): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export interface ServiceJwtVerifierOptions {
|
|
13
|
-
|
|
38
|
+
/**
|
|
39
|
+
* list of `aud` values accepted by this service; each entry is a bare DID or a DID with
|
|
40
|
+
* service fragment (e.g. `did:web:x.example#svc`), and incoming tokens must exact-match any entry.
|
|
41
|
+
*
|
|
42
|
+
* pass `null` to skip audience validation (accept any audience). an empty array rejects every
|
|
43
|
+
* audience, which is useful when a service wants to fail closed until configured.
|
|
44
|
+
*/
|
|
45
|
+
acceptAudiences: (Did | AtprotoAudience)[] | null;
|
|
14
46
|
resolver: DidDocumentResolver;
|
|
47
|
+
/**
|
|
48
|
+
* maximum token lifetime window in seconds. rejects tokens whose `exp` is
|
|
49
|
+
* more than this far in the future or whose `iat` is more than this far in
|
|
50
|
+
* the past. defaults to 300 (5 minutes), matching atproto convention.
|
|
51
|
+
*/
|
|
52
|
+
maxAge?: number;
|
|
53
|
+
/**
|
|
54
|
+
* clock-skew leeway in seconds applied to `exp` and `nbf` comparisons.
|
|
55
|
+
* defaults to 5 seconds.
|
|
56
|
+
*/
|
|
57
|
+
clockLeeway?: number;
|
|
58
|
+
/**
|
|
59
|
+
* optional replay-protection store. when provided, tokens must carry a
|
|
60
|
+
* `jti` claim and the verifier rejects any `(iss, jti)` the store reports
|
|
61
|
+
* as previously seen.
|
|
62
|
+
*/
|
|
63
|
+
replayStore?: ReplayStore;
|
|
15
64
|
}
|
|
16
65
|
|
|
17
66
|
export interface VerifyJwtOptions {
|
|
18
|
-
lxm: Nsid | Nsid[]
|
|
67
|
+
lxm: Nsid | Nsid[];
|
|
68
|
+
/** abort signal forwarded to DID resolution; falls back to `request.signal` in `verifyRequest`. */
|
|
69
|
+
signal?: AbortSignal;
|
|
19
70
|
}
|
|
20
71
|
|
|
21
72
|
export interface VerifiedJwt {
|
|
22
73
|
issuer: Did;
|
|
23
|
-
audience: Did;
|
|
24
|
-
lxm:
|
|
74
|
+
audience: Did | AtprotoAudience;
|
|
75
|
+
lxm: Nsid;
|
|
25
76
|
}
|
|
26
77
|
|
|
78
|
+
const BEARER_PREFIX = 'Bearer ';
|
|
79
|
+
|
|
27
80
|
export class ServiceJwtVerifier {
|
|
28
81
|
didDocResolver: DidDocumentResolver;
|
|
29
|
-
|
|
82
|
+
acceptAudiences: (Did | AtprotoAudience)[] | null;
|
|
83
|
+
maxAge: number;
|
|
84
|
+
clockLeeway: number;
|
|
85
|
+
replayStore?: ReplayStore;
|
|
30
86
|
|
|
31
87
|
constructor(options: ServiceJwtVerifierOptions) {
|
|
32
88
|
this.didDocResolver = options.resolver;
|
|
33
|
-
this.
|
|
89
|
+
this.acceptAudiences = options.acceptAudiences;
|
|
90
|
+
this.maxAge = options.maxAge ?? 5 * 60;
|
|
91
|
+
this.clockLeeway = options.clockLeeway ?? 5;
|
|
92
|
+
this.replayStore = options.replayStore;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* parse the Authorization header, verify the bearer token, and return the
|
|
97
|
+
* validated claims. throws {@link AuthRequiredError} with a populated
|
|
98
|
+
* `WWW-Authenticate: Bearer` challenge on every failure path.
|
|
99
|
+
*
|
|
100
|
+
* @param request incoming request; `request.signal` is forwarded to DID
|
|
101
|
+
* resolution unless `options.signal` overrides it.
|
|
102
|
+
* @param options verification options; `lxm` restricts which lexicon
|
|
103
|
+
* methods the token is allowed to invoke.
|
|
104
|
+
* @throws {AuthRequiredError} on missing header, malformed token,
|
|
105
|
+
* signature mismatch, audience/lxm rejection, replay, or expiry.
|
|
106
|
+
*/
|
|
107
|
+
async verifyRequest(request: Request, options: VerifyJwtOptions): Promise<VerifiedJwt> {
|
|
108
|
+
const authorization = request.headers.get('authorization');
|
|
109
|
+
if (authorization === null) {
|
|
110
|
+
throw new AuthRequiredError({
|
|
111
|
+
message: 'authorization header required',
|
|
112
|
+
wwwAuthenticate: { scheme: 'Bearer' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!authorization.startsWith(BEARER_PREFIX)) {
|
|
117
|
+
throw authError({ error: 'MissingBearer', description: 'expected a bearer token' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const token = authorization.slice(BEARER_PREFIX.length).trim();
|
|
121
|
+
const signal = options.signal ?? request.signal;
|
|
122
|
+
|
|
123
|
+
const result = await this.#verifyToken(token, { lxm: options.lxm, signal });
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
throw authError(result.error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result.value;
|
|
34
129
|
}
|
|
35
130
|
|
|
36
|
-
async #getSigningKey(
|
|
131
|
+
async #getSigningKey(
|
|
132
|
+
issuer: Did,
|
|
133
|
+
kid: SupportedKid,
|
|
134
|
+
noCache: boolean,
|
|
135
|
+
signal: AbortSignal,
|
|
136
|
+
): Promise<Result<FoundPublicKey, AuthError>> {
|
|
37
137
|
let didDocument: DidDocument;
|
|
38
138
|
let key: FoundPublicKey;
|
|
39
139
|
|
|
40
140
|
try {
|
|
41
|
-
didDocument = await this.didDocResolver.resolve(issuer, { noCache });
|
|
141
|
+
didDocument = await this.didDocResolver.resolve(issuer, { noCache, signal });
|
|
42
142
|
} catch {
|
|
43
143
|
return {
|
|
44
144
|
ok: false,
|
|
45
145
|
error: {
|
|
46
|
-
error: '
|
|
146
|
+
error: 'DidResolutionFailed',
|
|
47
147
|
description: `failed to retrieve did document for ${issuer}`,
|
|
48
148
|
},
|
|
49
149
|
};
|
|
50
150
|
}
|
|
51
151
|
|
|
52
|
-
const controller =
|
|
152
|
+
const controller = getVerificationMaterial(didDocument, kid);
|
|
53
153
|
if (!controller) {
|
|
54
154
|
return {
|
|
55
155
|
ok: false,
|
|
56
156
|
error: {
|
|
57
157
|
error: 'BadJwtIssuer',
|
|
58
|
-
description: `${issuer} does not have
|
|
158
|
+
description: `${issuer} does not have a ${kid} verification material`,
|
|
59
159
|
},
|
|
60
160
|
};
|
|
61
161
|
}
|
|
@@ -67,7 +167,7 @@ export class ServiceJwtVerifier {
|
|
|
67
167
|
ok: false,
|
|
68
168
|
error: {
|
|
69
169
|
error: 'BadJwtIssuer',
|
|
70
|
-
description: `${issuer} has invalid
|
|
170
|
+
description: `${issuer} has invalid ${kid} verification material`,
|
|
71
171
|
},
|
|
72
172
|
};
|
|
73
173
|
}
|
|
@@ -92,8 +192,11 @@ export class ServiceJwtVerifier {
|
|
|
92
192
|
}
|
|
93
193
|
}
|
|
94
194
|
|
|
95
|
-
async
|
|
96
|
-
|
|
195
|
+
async #verifyToken(
|
|
196
|
+
token: string,
|
|
197
|
+
options: { lxm: Nsid | Nsid[]; signal: AbortSignal },
|
|
198
|
+
): Promise<Result<VerifiedJwt, AuthError>> {
|
|
199
|
+
const parsed = parseJwt(token);
|
|
97
200
|
if (!parsed.ok) {
|
|
98
201
|
return parsed;
|
|
99
202
|
}
|
|
@@ -114,7 +217,33 @@ export class ServiceJwtVerifier {
|
|
|
114
217
|
}
|
|
115
218
|
}
|
|
116
219
|
|
|
117
|
-
|
|
220
|
+
// resolve the `kid` header (defaulting to `#atproto`) and restrict to the set of
|
|
221
|
+
// identifiers this verifier knows how to look up in the issuer's DID document.
|
|
222
|
+
// matches proposal 0014's "safe default" for SDKs.
|
|
223
|
+
const kid: string = header.kid ?? DEFAULT_KID;
|
|
224
|
+
if (kid !== DEFAULT_KID) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: {
|
|
228
|
+
error: 'BadJwtIssuer',
|
|
229
|
+
description: `unsupported signing key identifier (${kid})`,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const now = Math.floor(Date.now() / 1_000);
|
|
235
|
+
|
|
236
|
+
if (payload.nbf !== undefined && now < payload.nbf - this.clockLeeway) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
error: {
|
|
240
|
+
error: 'JwtNotYetValid',
|
|
241
|
+
description: `jwt is not yet valid`,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (now > payload.exp + this.clockLeeway) {
|
|
118
247
|
return {
|
|
119
248
|
ok: false,
|
|
120
249
|
error: {
|
|
@@ -124,20 +253,33 @@ export class ServiceJwtVerifier {
|
|
|
124
253
|
};
|
|
125
254
|
}
|
|
126
255
|
|
|
127
|
-
|
|
256
|
+
// prevent issuers from minting very long-lived tokens: the configured max-age
|
|
257
|
+
// window bounds how far `exp` can be in the future and how far `iat` can be in
|
|
258
|
+
// the past.
|
|
259
|
+
if (payload.exp - now > this.maxAge || (payload.iat !== undefined && now - payload.iat > this.maxAge)) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error: {
|
|
263
|
+
error: 'JwtTooOld',
|
|
264
|
+
description: `jwt exceeds maximum age (${this.maxAge}s)`,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (this.acceptAudiences !== null && !this.acceptAudiences.includes(payload.aud)) {
|
|
128
270
|
return {
|
|
129
271
|
ok: false,
|
|
130
272
|
error: {
|
|
131
|
-
error: '
|
|
132
|
-
description:
|
|
273
|
+
error: 'InvalidAudience',
|
|
274
|
+
description:
|
|
275
|
+
this.acceptAudiences.length === 0
|
|
276
|
+
? `jwt audience does not match (no audiences accepted)`
|
|
277
|
+
: `jwt audience does not match (expected one of: ${this.acceptAudiences.join(', ')})`,
|
|
133
278
|
},
|
|
134
279
|
};
|
|
135
280
|
}
|
|
136
281
|
|
|
137
|
-
if (
|
|
138
|
-
options?.lxm != null &&
|
|
139
|
-
(typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm!))
|
|
140
|
-
) {
|
|
282
|
+
if (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm)) {
|
|
141
283
|
return {
|
|
142
284
|
ok: false,
|
|
143
285
|
error: {
|
|
@@ -147,7 +289,22 @@ export class ServiceJwtVerifier {
|
|
|
147
289
|
};
|
|
148
290
|
}
|
|
149
291
|
|
|
150
|
-
|
|
292
|
+
let jti: string | undefined;
|
|
293
|
+
if (this.replayStore !== undefined) {
|
|
294
|
+
if (payload.jti === undefined) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: {
|
|
298
|
+
error: 'BadJwt',
|
|
299
|
+
description: `jwt is missing the jti claim (required for replay protection)`,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
jti = payload.jti;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const key = await this.#getSigningKey(payload.iss, kid, false, options.signal);
|
|
151
308
|
if (!key.ok) {
|
|
152
309
|
return key;
|
|
153
310
|
}
|
|
@@ -165,7 +322,7 @@ export class ServiceJwtVerifier {
|
|
|
165
322
|
|
|
166
323
|
if (!isValid) {
|
|
167
324
|
// try again, uncached
|
|
168
|
-
const freshKey = await this.#getSigningKey(payload.iss, true);
|
|
325
|
+
const freshKey = await this.#getSigningKey(payload.iss, kid, true, options.signal);
|
|
169
326
|
if (!freshKey.ok) {
|
|
170
327
|
return freshKey;
|
|
171
328
|
}
|
|
@@ -203,6 +360,22 @@ export class ServiceJwtVerifier {
|
|
|
203
360
|
};
|
|
204
361
|
}
|
|
205
362
|
|
|
363
|
+
// replay-store check runs after signature verification so forged tokens
|
|
364
|
+
// can't burn entries (memory dos) or evict legitimate `(iss, jti)` pairs
|
|
365
|
+
// before the real request lands.
|
|
366
|
+
if (this.replayStore !== undefined && jti !== undefined) {
|
|
367
|
+
const unique = await this.replayStore.check({ iss: payload.iss, jti }, this.maxAge);
|
|
368
|
+
if (!unique) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: {
|
|
372
|
+
error: 'NonceNotUnique',
|
|
373
|
+
description: `jwt has been used before`,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
206
379
|
return {
|
|
207
380
|
ok: true,
|
|
208
381
|
value: {
|
|
@@ -213,3 +386,10 @@ export class ServiceJwtVerifier {
|
|
|
213
386
|
};
|
|
214
387
|
}
|
|
215
388
|
}
|
|
389
|
+
|
|
390
|
+
const authError = (err: AuthError): AuthRequiredError => {
|
|
391
|
+
return new AuthRequiredError({
|
|
392
|
+
message: err.description,
|
|
393
|
+
wwwAuthenticate: { scheme: 'Bearer', params: { error: err.error } },
|
|
394
|
+
});
|
|
395
|
+
};
|
package/lib/auth/jwt.ts
CHANGED
|
@@ -1,57 +1,70 @@
|
|
|
1
|
+
import { isAtprotoAudience } from '@atcute/identity';
|
|
1
2
|
import type { Did, Nsid } from '@atcute/lexicons';
|
|
2
|
-
import { isDid, isNsid } from '@atcute/lexicons/syntax';
|
|
3
|
+
import { isDid, isNsid, type AtprotoAudience } from '@atcute/lexicons/syntax';
|
|
3
4
|
import { fromBase64Url } from '@atcute/multibase';
|
|
4
5
|
import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
|
|
5
6
|
|
|
6
|
-
import * as v from '
|
|
7
|
+
import * as v from 'valibot';
|
|
7
8
|
|
|
8
9
|
import type { Result } from '../types/misc.ts';
|
|
9
10
|
|
|
10
11
|
import type { AuthError } from './types.ts';
|
|
11
12
|
|
|
12
|
-
const didString = v.
|
|
13
|
-
const
|
|
13
|
+
const didString = v.custom<Did>(isDid, `must be a did`);
|
|
14
|
+
const audienceString = v.custom<Did | AtprotoAudience>(
|
|
15
|
+
(input) => isAtprotoAudience(input) || isDid(input),
|
|
16
|
+
`must be a did or atproto audience`,
|
|
17
|
+
);
|
|
18
|
+
const nsidString = v.custom<Nsid>(isNsid, `must be an nsid`);
|
|
14
19
|
|
|
15
|
-
const integer = v.
|
|
20
|
+
const integer = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
|
|
16
21
|
|
|
17
22
|
export interface JwtHeader {
|
|
18
23
|
typ?: string;
|
|
19
24
|
alg: string;
|
|
25
|
+
/** signing key identifier; a DID fragment, defaults to `#atproto` when absent */
|
|
26
|
+
kid?: string;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
const jwtHeader: v.
|
|
23
|
-
typ: v.
|
|
29
|
+
const jwtHeader: v.GenericSchema<unknown, JwtHeader> = v.looseObject({
|
|
30
|
+
typ: v.optional(v.string()),
|
|
24
31
|
alg: v.string(),
|
|
32
|
+
kid: v.optional(v.string()),
|
|
25
33
|
});
|
|
26
34
|
|
|
27
35
|
export interface JwtPayload {
|
|
28
36
|
iss: Did;
|
|
29
|
-
aud: Did;
|
|
37
|
+
aud: Did | AtprotoAudience;
|
|
30
38
|
exp: number;
|
|
31
39
|
iat?: number;
|
|
32
|
-
|
|
40
|
+
/** not-before time; token is invalid before this unix timestamp */
|
|
41
|
+
nbf?: number;
|
|
42
|
+
lxm: Nsid;
|
|
33
43
|
jti?: string;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
const jwtPayload: v.
|
|
37
|
-
.
|
|
46
|
+
const jwtPayload: v.GenericSchema<unknown, JwtPayload> = v.pipe(
|
|
47
|
+
v.looseObject({
|
|
38
48
|
/** issuer */
|
|
39
49
|
iss: didString,
|
|
40
|
-
/** target audience */
|
|
41
|
-
aud:
|
|
50
|
+
/** target audience; a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */
|
|
51
|
+
aud: audienceString,
|
|
42
52
|
/** expiration time */
|
|
43
53
|
exp: integer,
|
|
44
54
|
/** creation time */
|
|
45
|
-
iat:
|
|
46
|
-
/**
|
|
47
|
-
|
|
55
|
+
iat: v.optional(integer),
|
|
56
|
+
/** not-before time */
|
|
57
|
+
nbf: v.optional(integer),
|
|
58
|
+
/** xrpc operation being invoked; required per atproto service auth spec */
|
|
59
|
+
lxm: nsidString,
|
|
48
60
|
/** unique identifier */
|
|
49
|
-
jti: v.
|
|
50
|
-
})
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
jti: v.optional(v.string()),
|
|
62
|
+
}),
|
|
63
|
+
v.forward(
|
|
64
|
+
v.check(({ iat, exp }) => iat === undefined || exp > iat, `expiry time must be greater than issued time`),
|
|
65
|
+
['exp'],
|
|
66
|
+
),
|
|
67
|
+
);
|
|
55
68
|
|
|
56
69
|
export interface ParsedJwt {
|
|
57
70
|
header: JwtHeader;
|
|
@@ -60,21 +73,21 @@ export interface ParsedJwt {
|
|
|
60
73
|
signature: Uint8Array<ArrayBuffer>;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
const readJwtPortion = <T>(schema: v.
|
|
76
|
+
const readJwtPortion = <T>(schema: v.GenericSchema<unknown, T>, input: string): Result<T, AuthError> => {
|
|
64
77
|
try {
|
|
65
78
|
const raw = decodeUtf8From(fromBase64Url(input));
|
|
66
79
|
const json = JSON.parse(raw);
|
|
67
80
|
|
|
68
|
-
const result =
|
|
69
|
-
if (result.
|
|
70
|
-
return result;
|
|
81
|
+
const result = v.safeParse(schema, json);
|
|
82
|
+
if (result.success) {
|
|
83
|
+
return { ok: true, value: result.output };
|
|
71
84
|
}
|
|
72
85
|
} catch {}
|
|
73
86
|
|
|
74
87
|
return {
|
|
75
88
|
ok: false,
|
|
76
89
|
error: {
|
|
77
|
-
error: `
|
|
90
|
+
error: `BadJwt`,
|
|
78
91
|
description: `jwt is malformed`,
|
|
79
92
|
},
|
|
80
93
|
};
|
|
@@ -88,7 +101,7 @@ const readJwtSignature = (input: string): Result<Uint8Array<ArrayBuffer>, AuthEr
|
|
|
88
101
|
return {
|
|
89
102
|
ok: false,
|
|
90
103
|
error: {
|
|
91
|
-
error: `
|
|
104
|
+
error: `BadJwt`,
|
|
92
105
|
description: `jwt is malformed`,
|
|
93
106
|
},
|
|
94
107
|
};
|
|
@@ -100,7 +113,7 @@ export const parseJwt = (jwtString: string): Result<ParsedJwt, AuthError> => {
|
|
|
100
113
|
return {
|
|
101
114
|
ok: false,
|
|
102
115
|
error: {
|
|
103
|
-
error: `
|
|
116
|
+
error: `BadJwt`,
|
|
104
117
|
description: `jwt is malformed`,
|
|
105
118
|
},
|
|
106
119
|
};
|