Skip to content

Commit e71d265

Browse files
Minor oauth jwk tweaks (#4256)
* Minor oauth jwk changes * tidy
1 parent 591de19 commit e71d265

File tree

9 files changed

+67
-27
lines changed

9 files changed

+67
-27
lines changed

.changeset/breezy-icons-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@atproto/oauth-provider": patch
3+
---
4+
5+
Improve error in case of invalid loopback client metadata

packages/oauth/jwk/src/jwk.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export type KeyUsage = (typeof KEY_USAGE)[number]
5353
* @see {@link https://www.iana.org/assignments/jose/jose.xhtml#web-key-parameters IANA "JSON Web Key Parameters" registry}
5454
*/
5555
const jwkBaseSchema = z.object({
56-
kty: z.string().min(1).optional(),
56+
kty: z.string().min(1),
57+
alg: z.string().min(1).optional(),
58+
kid: z.string().min(1).optional(),
5759
use: z.enum(['sig', 'enc']).optional(),
5860
key_ops: z
5961
.array(keyUsageSchema)
@@ -64,8 +66,6 @@ const jwkBaseSchema = z.object({
6466
message: 'key_ops must not contain duplicates',
6567
})
6668
.optional(),
67-
alg: z.string().min(1).optional(),
68-
kid: z.string().min(1).optional(),
6969

7070
x5c: z.array(z.string()).optional(), // X.509 Certificate Chain
7171
x5t: z.string().min(1).optional(), // X.509 Certificate SHA-1 Thumbprint
@@ -208,6 +208,10 @@ export type Jwk = z.output<typeof jwkSchema>
208208
export const jwkValidator = jwkSchema
209209

210210
export const jwkPubSchema = jwkSchema
211+
.refine(hasKid, {
212+
message: '"kid" is required',
213+
path: ['kid'],
214+
})
211215
// @NOTE for legacy reasons, we don't impose the presence of either "use" or "key_ops"
212216
.refine(isPublicJwk, {
213217
message: 'private key not allowed',
@@ -216,10 +220,6 @@ export const jwkPubSchema = jwkSchema
216220
message: '"key_ops" must not contain private key usage for public keys',
217221
path: ['key_ops'],
218222
})
219-
.refine(hasKid, {
220-
message: '"kid" is required',
221-
path: ['kid'],
222-
})
223223

224224
export type PublicJwk = z.output<typeof jwkPubSchema>
225225

packages/oauth/jwk/src/jwks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const jwksSchema = z.object({
1818
}),
1919
})
2020

21-
export type Jwks = z.infer<typeof jwksSchema>
21+
export type Jwks = z.output<typeof jwksSchema>
2222

2323
/**
2424
* Public JSON Web Key Set schema.
@@ -36,4 +36,4 @@ export const jwksPubSchema = z.object({
3636
}),
3737
})
3838

39-
export type JwksPub = z.infer<typeof jwksPubSchema>
39+
export type JwksPub = z.output<typeof jwksPubSchema>

packages/oauth/jwk/src/key.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export abstract class Key<J extends Jwk = Jwk> {
5858
...this.jwk,
5959
d: undefined,
6060
k: undefined,
61+
use: undefined,
6162
key_ops: buildPublicKeyOps(this.keyOps) ?? PUBLIC_KEY_USAGE,
6263
})
6364

@@ -115,12 +116,12 @@ export abstract class Key<J extends Jwk = Jwk> {
115116
return Object.freeze(Array.from(jwkAlgorithms(this.jwk)))
116117
}
117118

118-
get revoked() {
119-
return this.jwk.revoked
119+
get isRevoked() {
120+
return this.jwk.revoked != null
120121
}
121122

122123
isActive(options?: ActivityCheckOptions) {
123-
if (!options?.allowRevoked && this.revoked) return false
124+
if (!options?.allowRevoked && this.isRevoked) return false
124125

125126
const tolerance = options?.clockTolerance ?? 0
126127
if (tolerance !== Infinity) {
@@ -166,7 +167,9 @@ export abstract class Key<J extends Jwk = Jwk> {
166167
(this.use === 'enc' && isEncKeyUsage(opts.usage))
167168
if (!matchesUse) return false
168169

169-
// @NOTE This is only relevant when "key_ops" and "use" are undefined
170+
// @NOTE This is only relevant when "key_ops" and "use" are undefined.
171+
// This line also ensures that when "opts.usage" is a private key usage
172+
// (e.g. "sign"), the key is indeed a private key.
170173
const matchesKeyType = this.isPrivate || isPublicKeyUsage(opts.usage)
171174
if (!matchesKeyType) return false
172175
}

packages/oauth/jwk/src/keyset.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,19 @@ import {
2020
preferredOrderCmp,
2121
} from './util.js'
2222

23+
export type { ActivityCheckOptions, KeyMatchOptions }
24+
export type FindKeyOptions = KeyMatchOptions & ActivityCheckOptions
25+
2326
export type JwtSignHeader = Override<
2427
JwtHeader,
25-
Pick<KeyMatchOptions, 'alg' | 'kid'>
28+
Pick<FindKeyOptions, 'alg' | 'kid'>
2629
>
2730

2831
export type JwtPayloadGetter<P = JwtPayload> = (
2932
header: JwtHeader,
3033
key: Key,
3134
) => P | PromiseLike<P>
3235

33-
export type { KeyMatchOptions }
34-
3536
const extractPrivateJwk = (key: Key) => key.privateJwk
3637
const extractPublicJwk = (key: Key) => key.publicJwk
3738

@@ -117,18 +118,25 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
117118
return this.keys.some((key) => key.kid === kid)
118119
}
119120

120-
get(options: KeyMatchOptions & ActivityCheckOptions): K {
121-
for (const key of this.list(options)) {
122-
return key
123-
}
121+
get(options: FindKeyOptions): K {
122+
const key = this.find(options)
123+
if (key) return key
124124

125125
throw new JwkError(
126126
`Key not found ${options.kid ?? options.alg ?? options.usage ?? '<unknown>'}`,
127127
ERR_JWK_NOT_FOUND,
128128
)
129129
}
130130

131-
*list<O extends KeyMatchOptions & ActivityCheckOptions>(options: O) {
131+
find(options: FindKeyOptions): K | undefined {
132+
for (const key of this.list(options)) {
133+
return key
134+
}
135+
136+
return undefined
137+
}
138+
139+
*list<O extends FindKeyOptions>(options: O) {
132140
for (const key of this) {
133141
if (key.isActive(options) && key.matches(options)) {
134142
yield key
@@ -140,7 +148,8 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
140148
kid,
141149
alg,
142150
usage,
143-
}: KeyMatchOptions & { usage: PrivateKeyUsage }): {
151+
...options
152+
}: FindKeyOptions & { usage: PrivateKeyUsage }): {
144153
key: Key
145154
alg: string
146155
} {
@@ -149,7 +158,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
149158
// Allow the loop bellow to return early when a single "alg" is provided
150159
if (Array.isArray(alg) && alg.length === 1) alg = alg[0]
151160

152-
for (const key of this.list({ kid, alg, usage })) {
161+
for (const key of this.list({ ...options, kid, alg, usage })) {
153162
// Skip negotiation if a single "alg" was provided
154163
if (typeof alg === 'string') return { key, alg }
155164

@@ -196,6 +205,7 @@ export class Keyset<K extends Key = Key> implements Iterable<K> {
196205
alg: sAlg,
197206
kid: sKid,
198207
usage: 'sign',
208+
allowRevoked: false, // For explicitness (default value is false)
199209
})
200210
const protectedHeader = { ...header, alg, kid: key.kid }
201211

packages/oauth/oauth-client-browser-example/src/main.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ const clientMetadata = buildAtprotoLoopbackClientMetadata({
2020
redirect_uris: [LOOPBACK_CANONICAL_LOCATION],
2121
})
2222

23+
const queryClient = new QueryClient()
24+
2325
createRoot(document.getElementById('root')!).render(
2426
<StrictMode>
25-
<QueryClientProvider client={new QueryClient()}>
27+
<QueryClientProvider client={queryClient}>
2628
<AuthProvider
2729
clientMetadata={clientMetadata}
2830
plcDirectoryUrl={PLC_DIRECTORY_URL}

packages/oauth/oauth-client/src/oauth-session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const ReadableStream = globalThis.ReadableStream as
1414
| typeof globalThis.ReadableStream
1515
| undefined
1616

17-
export type { AtprotoOAuthScope }
17+
export type { AtprotoDid, AtprotoOAuthScope }
1818
export type TokenInfo = {
1919
expiresAt?: Date
2020
expired?: boolean

packages/oauth/oauth-provider/src/client/client-manager.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,24 @@ export class ClientManager {
176176
throw new InvalidClientMetadataError('Loopback clients are not allowed')
177177
}
178178

179-
const metadata = oauthClientMetadataSchema.parse(
180-
await loopbackMetadata(clientId),
179+
const metadataRaw = await callAsync(loopbackMetadata, clientId).catch(
180+
(err) => {
181+
throw InvalidClientMetadataError.from(
182+
err,
183+
`Invalid loopback client id "${clientId}"`,
184+
)
185+
},
181186
)
182187

188+
const metadata = await oauthClientMetadataSchema
189+
.parseAsync(metadataRaw)
190+
.catch((err) => {
191+
throw InvalidClientMetadataError.from(
192+
err,
193+
`Invalid loopback client metadata for "${clientId}"`,
194+
)
195+
})
196+
183197
return this.validateClientMetadata(clientId, metadata)
184198
}
185199

packages/oauth/oauth-types/src/atproto-oauth-scope.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export function asAtprotoOAuthScope<I extends string>(input: I) {
1919
throw new TypeError(`Value must contain "${ATPROTO_SCOPE_VALUE}" scope value`)
2020
}
2121

22+
export function assertAtprotoOAuthScope(
23+
input: string,
24+
): asserts input is AtprotoOAuthScope {
25+
void asAtprotoOAuthScope(input)
26+
}
27+
2228
export const atprotoOAuthScopeSchema = z.string().refine(isAtprotoOAuthScope, {
2329
message: 'Invalid ATProto OAuth scope',
2430
})

0 commit comments

Comments
 (0)