copy over a few files for now
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@
|
|||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tlds": "^1.234.0",
|
"tlds": "^1.234.0",
|
||||||
"zeego": "^1.6.2",
|
"zeego": "^1.6.2",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/dev-env": "^0.2.28",
|
"@atproto/dev-env": "^0.2.28",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {fromByteArray, toByteArray} from 'base64-js'
|
||||||
|
|
||||||
|
// Old Node implementations do not support "base64url"
|
||||||
|
const Buffer = (Buffer => {
|
||||||
|
if (typeof Buffer === 'function') {
|
||||||
|
try {
|
||||||
|
Buffer.from('', 'base64url')
|
||||||
|
return Buffer
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})(globalThis.Buffer)
|
||||||
|
|
||||||
|
export const b64uDecode: (b64u: string) => Uint8Array = Buffer
|
||||||
|
? b64u => Buffer.from(b64u, 'base64url')
|
||||||
|
: b64u => {
|
||||||
|
// toByteArray requires padding but not to replace '-' and '_'
|
||||||
|
const pad = b64u.length % 4
|
||||||
|
const b64 = b64u.padEnd(b64u.length + (pad > 0 ? 4 - pad : 0), '=')
|
||||||
|
return toByteArray(b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const b64uEncode = Buffer
|
||||||
|
? (bytes: Uint8Array) => {
|
||||||
|
const buffer = bytes instanceof Buffer ? bytes : Buffer.from(bytes)
|
||||||
|
return buffer.toString('base64url')
|
||||||
|
}
|
||||||
|
: (bytes: Uint8Array): string =>
|
||||||
|
fromByteArray(bytes)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/[=]+$/g, '')
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const FALLBACK_ALG = 'ES256'
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Jwk } from './jwk.js'
|
||||||
|
|
||||||
|
declare const process: undefined | { versions?: { node?: string } }
|
||||||
|
const IS_NODE_RUNTIME =
|
||||||
|
typeof process !== 'undefined' && typeof process?.versions?.node === 'string'
|
||||||
|
|
||||||
|
export function* jwkAlgorithms(jwk: Jwk): Generator<string> {
|
||||||
|
// Ed25519, Ed448, and secp256k1 always have "alg"
|
||||||
|
// OKP always has "use"
|
||||||
|
if (jwk.alg) {
|
||||||
|
yield jwk.alg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (jwk.kty) {
|
||||||
|
case 'EC': {
|
||||||
|
if (jwk.use === 'enc' || jwk.use === undefined) {
|
||||||
|
yield 'ECDH-ES'
|
||||||
|
yield 'ECDH-ES+A128KW'
|
||||||
|
yield 'ECDH-ES+A192KW'
|
||||||
|
yield 'ECDH-ES+A256KW'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwk.use === 'sig' || jwk.use === undefined) {
|
||||||
|
const crv = 'crv' in jwk ? jwk.crv : undefined
|
||||||
|
switch (crv) {
|
||||||
|
case 'P-256':
|
||||||
|
case 'P-384':
|
||||||
|
yield `ES${crv.slice(-3)}`.replace('21', '12')
|
||||||
|
break
|
||||||
|
case 'P-521':
|
||||||
|
yield 'ES512'
|
||||||
|
break
|
||||||
|
case 'secp256k1':
|
||||||
|
if (IS_NODE_RUNTIME) yield 'ES256K'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new TypeError(`Unsupported crv "${crv}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'OKP': {
|
||||||
|
if (!jwk.use) throw new TypeError('Missing "use" Parameter value')
|
||||||
|
yield 'ECDH-ES'
|
||||||
|
yield 'ECDH-ES+A128KW'
|
||||||
|
yield 'ECDH-ES+A192KW'
|
||||||
|
yield 'ECDH-ES+A256KW'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RSA': {
|
||||||
|
if (jwk.use === 'enc' || jwk.use === undefined) {
|
||||||
|
yield 'RSA-OAEP'
|
||||||
|
yield 'RSA-OAEP-256'
|
||||||
|
yield 'RSA-OAEP-384'
|
||||||
|
yield 'RSA-OAEP-512'
|
||||||
|
if (IS_NODE_RUNTIME) yield 'RSA1_5'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwk.use === 'sig' || jwk.use === undefined) {
|
||||||
|
yield 'PS256'
|
||||||
|
yield 'PS384'
|
||||||
|
yield 'PS512'
|
||||||
|
yield 'RS256'
|
||||||
|
yield 'RS384'
|
||||||
|
yield 'RS512'
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'oct': {
|
||||||
|
if (jwk.use === 'enc' || jwk.use === undefined) {
|
||||||
|
yield 'A128GCMKW'
|
||||||
|
yield 'A192GCMKW'
|
||||||
|
yield 'A256GCMKW'
|
||||||
|
yield 'A128KW'
|
||||||
|
yield 'A192KW'
|
||||||
|
yield 'A256KW'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwk.use === 'sig' || jwk.use === undefined) {
|
||||||
|
yield 'HS256'
|
||||||
|
yield 'HS384'
|
||||||
|
yield 'HS512'
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported kty "${jwk.kty}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './alg.js'
|
||||||
|
export * from './jwk.js'
|
||||||
|
export * from './jwks.js'
|
||||||
|
export * from './jwt.js'
|
||||||
|
export * from './jwt-decode.js'
|
||||||
|
export * from './jwt-verify.js'
|
||||||
|
export * from './key.js'
|
||||||
|
export * from './keyset.js'
|
||||||
|
export * from './util.js'
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const keyUsageSchema = z.enum([
|
||||||
|
'sign',
|
||||||
|
'verify',
|
||||||
|
'encrypt',
|
||||||
|
'decrypt',
|
||||||
|
'wrapKey',
|
||||||
|
'unwrapKey',
|
||||||
|
'deriveKey',
|
||||||
|
'deriveBits',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type KeyUsage = z.infer<typeof keyUsageSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "use" and "key_ops" JWK members SHOULD NOT be used together;
|
||||||
|
* however, if both are used, the information they convey MUST be
|
||||||
|
* consistent. Applications should specify which of these members they
|
||||||
|
* use, if either is to be used by the application.
|
||||||
|
*
|
||||||
|
* @todo Actually check that "use" and "key_ops" are consistent when both are present.
|
||||||
|
* @see {@link https://datatracker.ietf.org/doc/html/rfc7517#section-4.3}
|
||||||
|
*/
|
||||||
|
export const jwkBaseSchema = z.object({
|
||||||
|
kty: z.string().min(1),
|
||||||
|
alg: z.string().min(1).optional(),
|
||||||
|
kid: z.string().min(1).optional(),
|
||||||
|
ext: z.boolean().optional(),
|
||||||
|
use: z.enum(['sig', 'enc']).optional(),
|
||||||
|
key_ops: z.array(keyUsageSchema).readonly().optional(),
|
||||||
|
|
||||||
|
x5c: z.array(z.string()).readonly().optional(), // X.509 Certificate Chain
|
||||||
|
x5t: z.string().min(1).optional(), // X.509 Certificate SHA-1 Thumbprint
|
||||||
|
'x5t#S256': z.string().min(1).optional(), // X.509 Certificate SHA-256 Thumbprint
|
||||||
|
x5u: z.string().url().optional(), // X.509 URL
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo: properly implement this
|
||||||
|
*/
|
||||||
|
export const jwkRsaKeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z.literal('RSA'),
|
||||||
|
alg: z
|
||||||
|
.enum(['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'])
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
n: z.string().min(1), // Modulus
|
||||||
|
e: z.string().min(1), // Exponent
|
||||||
|
|
||||||
|
d: z.string().min(1).optional(), // Private Exponent
|
||||||
|
p: z.string().min(1).optional(), // First Prime Factor
|
||||||
|
q: z.string().min(1).optional(), // Second Prime Factor
|
||||||
|
dp: z.string().min(1).optional(), // First Factor CRT Exponent
|
||||||
|
dq: z.string().min(1).optional(), // Second Factor CRT Exponent
|
||||||
|
qi: z.string().min(1).optional(), // First CRT Coefficient
|
||||||
|
oth: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
r: z.string().optional(),
|
||||||
|
d: z.string().optional(),
|
||||||
|
t: z.string().optional(),
|
||||||
|
})
|
||||||
|
.readonly(),
|
||||||
|
)
|
||||||
|
.nonempty()
|
||||||
|
.readonly()
|
||||||
|
.optional(), // Other Primes Info
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkEcKeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z.literal('EC'),
|
||||||
|
alg: z.enum(['ES256', 'ES384', 'ES512']).optional(),
|
||||||
|
crv: z.enum(['P-256', 'P-384', 'P-521']),
|
||||||
|
|
||||||
|
x: z.string().min(1),
|
||||||
|
y: z.string().min(1),
|
||||||
|
|
||||||
|
d: z.string().min(1).optional(), // ECC Private Key
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkEcSecp256k1KeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z.literal('EC'),
|
||||||
|
alg: z.enum(['ES256K']).optional(),
|
||||||
|
crv: z.enum(['secp256k1']),
|
||||||
|
|
||||||
|
x: z.string().min(1),
|
||||||
|
y: z.string().min(1),
|
||||||
|
|
||||||
|
d: z.string().min(1).optional(), // ECC Private Key
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkOkpKeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z.literal('OKP'),
|
||||||
|
alg: z.enum(['EdDSA']).optional(),
|
||||||
|
crv: z.enum(['Ed25519', 'Ed448']),
|
||||||
|
|
||||||
|
x: z.string().min(1),
|
||||||
|
d: z.string().min(1).optional(), // ECC Private Key
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkSymKeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z.literal('oct'), // Octet Sequence (used to represent symmetric keys)
|
||||||
|
alg: z.enum(['HS256', 'HS384', 'HS512']).optional(),
|
||||||
|
|
||||||
|
k: z.string(), // Key Value (base64url encoded)
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkUnknownKeySchema = jwkBaseSchema
|
||||||
|
.extend({
|
||||||
|
kty: z
|
||||||
|
.string()
|
||||||
|
.refine((v) => v !== 'RSA' && v !== 'EC' && v !== 'OKP' && v !== 'oct'),
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export const jwkSchema = z.union([
|
||||||
|
jwkUnknownKeySchema,
|
||||||
|
jwkRsaKeySchema,
|
||||||
|
jwkEcKeySchema,
|
||||||
|
jwkEcSecp256k1KeySchema,
|
||||||
|
jwkOkpKeySchema,
|
||||||
|
jwkSymKeySchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type Jwk = z.infer<typeof jwkSchema>
|
||||||
|
|
||||||
|
export const jwkPubSchema = jwkSchema
|
||||||
|
.refine((k) => k.kid != null, 'kid is required')
|
||||||
|
.refine((k) => k.use != null || k.key_ops != null, 'use or key_ops required')
|
||||||
|
.refine(
|
||||||
|
(k) =>
|
||||||
|
!k.use ||
|
||||||
|
!k.key_ops ||
|
||||||
|
k.key_ops.every((o) =>
|
||||||
|
k.use === 'sig'
|
||||||
|
? o === 'sign' || o === 'verify'
|
||||||
|
: o === 'encrypt' || o === 'decrypt',
|
||||||
|
),
|
||||||
|
'use and key_ops must be consistent',
|
||||||
|
)
|
||||||
|
.refine((k) => !('k' in k) && !('d' in k), 'private key not allowed')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { jwkPubSchema, jwkSchema } from './jwk.js'
|
||||||
|
|
||||||
|
export const jwksSchema = z
|
||||||
|
.object({
|
||||||
|
keys: z.array(jwkSchema).readonly(),
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export type Jwks = z.infer<typeof jwksSchema>
|
||||||
|
|
||||||
|
export const jwksPubSchema = z
|
||||||
|
.object({
|
||||||
|
keys: z.array(jwkPubSchema).readonly(),
|
||||||
|
})
|
||||||
|
.readonly()
|
||||||
|
|
||||||
|
export type JwksPub = z.infer<typeof jwksPubSchema>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { b64uDecode } from '@atproto/b64'
|
||||||
|
|
||||||
|
import { ui8ToString } from './util.js'
|
||||||
|
import {
|
||||||
|
JwtHeader,
|
||||||
|
JwtPayload,
|
||||||
|
jwtHeaderSchema,
|
||||||
|
jwtPayloadSchema,
|
||||||
|
} from './jwt.js'
|
||||||
|
|
||||||
|
export function unsafeDecodeJwt(jwt: string): {
|
||||||
|
header: JwtHeader
|
||||||
|
payload: JwtPayload
|
||||||
|
} {
|
||||||
|
const { 0: headerEnc, 1: payloadEnc, length } = jwt.split('.')
|
||||||
|
if (length > 3 || length < 2) {
|
||||||
|
throw new TypeError('invalid JWT input')
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = jwtHeaderSchema.parse(
|
||||||
|
JSON.parse(ui8ToString(b64uDecode(headerEnc!))),
|
||||||
|
)
|
||||||
|
if (length === 2 && header?.alg !== 'none') {
|
||||||
|
throw new TypeError('invalid JWT input')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwtPayloadSchema.parse(
|
||||||
|
JSON.parse(ui8ToString(b64uDecode(payloadEnc!))),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { header, payload }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { JwtHeader, JwtPayload } from './jwt.js'
|
||||||
|
import { RequiredKey } from './util.js'
|
||||||
|
|
||||||
|
export type VerifyOptions<C extends string = string> = {
|
||||||
|
audience?: string | readonly string[]
|
||||||
|
clockTolerance?: string | number
|
||||||
|
issuer?: string | readonly string[]
|
||||||
|
maxTokenAge?: string | number
|
||||||
|
subject?: string
|
||||||
|
typ?: string
|
||||||
|
currentDate?: Date
|
||||||
|
requiredClaims?: readonly C[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerifyPayload = Record<string, unknown>
|
||||||
|
|
||||||
|
export type VerifyResult<P extends VerifyPayload, C extends string> = {
|
||||||
|
payload: RequiredKey<P & JwtPayload, C>
|
||||||
|
protectedHeader: JwtHeader
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { jwkPubSchema } from './jwk.js'
|
||||||
|
|
||||||
|
export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/
|
||||||
|
export const jwtSchema = z
|
||||||
|
.string()
|
||||||
|
.min(5)
|
||||||
|
.refinement(
|
||||||
|
(data: string): data is `${string}.${string}.${string}` =>
|
||||||
|
JWT_REGEXP.test(data),
|
||||||
|
{
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Must be a JWT',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isJwt = (data: unknown): data is Jwt =>
|
||||||
|
jwtSchema.safeParse(data).success
|
||||||
|
|
||||||
|
export type Jwt = z.infer<typeof jwtSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4}
|
||||||
|
*/
|
||||||
|
export const jwtHeaderSchema = z.object({
|
||||||
|
/** "alg" (Algorithm) Header Parameter */
|
||||||
|
alg: z.string(),
|
||||||
|
/** "jku" (JWK Set URL) Header Parameter */
|
||||||
|
jku: z.string().url().optional(),
|
||||||
|
/** "jwk" (JSON Web Key) Header Parameter */
|
||||||
|
jwk: z
|
||||||
|
.object({
|
||||||
|
kty: z.string(),
|
||||||
|
crv: z.string().optional(),
|
||||||
|
x: z.string().optional(),
|
||||||
|
y: z.string().optional(),
|
||||||
|
e: z.string().optional(),
|
||||||
|
n: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
/** "kid" (Key ID) Header Parameter */
|
||||||
|
kid: z.string().optional(),
|
||||||
|
/** "x5u" (X.509 URL) Header Parameter */
|
||||||
|
x5u: z.string().optional(),
|
||||||
|
/** "x5c" (X.509 Certificate Chain) Header Parameter */
|
||||||
|
x5c: z.array(z.string()).optional(),
|
||||||
|
/** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */
|
||||||
|
x5t: z.string().optional(),
|
||||||
|
/** "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter */
|
||||||
|
'x5t#S256': z.string().optional(),
|
||||||
|
/** "typ" (Type) Header Parameter */
|
||||||
|
typ: z.string().optional(),
|
||||||
|
/** "cty" (Content Type) Header Parameter */
|
||||||
|
cty: z.string().optional(),
|
||||||
|
/** "crit" (Critical) Header Parameter */
|
||||||
|
crit: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type JwtHeader = z.infer<typeof jwtHeaderSchema>
|
||||||
|
|
||||||
|
// https://www.iana.org/assignments/jwt/jwt.xhtml
|
||||||
|
export const jwtPayloadSchema = z.object({
|
||||||
|
iss: z.string().optional(),
|
||||||
|
aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(),
|
||||||
|
sub: z.string().optional(),
|
||||||
|
exp: z.number().int().optional(),
|
||||||
|
nbf: z.number().int().optional(),
|
||||||
|
iat: z.number().int().optional(),
|
||||||
|
jti: z.string().optional(),
|
||||||
|
htm: z.string().optional(),
|
||||||
|
htu: z.string().optional(),
|
||||||
|
ath: z.string().optional(),
|
||||||
|
acr: z.string().optional(),
|
||||||
|
azp: z.string().optional(),
|
||||||
|
amr: z.array(z.string()).optional(),
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7800
|
||||||
|
cnf: z
|
||||||
|
.object({
|
||||||
|
kid: z.string().optional(), // Key ID
|
||||||
|
jwk: jwkPubSchema.optional(), // JWK
|
||||||
|
jwe: z.string().optional(), // Encrypted key
|
||||||
|
jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided)
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9449#section-6.1
|
||||||
|
jkt: z.string().optional(),
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc8705
|
||||||
|
'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9203
|
||||||
|
osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
client_id: z.string().optional(),
|
||||||
|
|
||||||
|
scope: z.string().optional(),
|
||||||
|
nonce: z.string().optional(),
|
||||||
|
|
||||||
|
at_hash: z.string().optional(),
|
||||||
|
c_hash: z.string().optional(),
|
||||||
|
s_hash: z.string().optional(),
|
||||||
|
auth_time: z.number().int().optional(),
|
||||||
|
|
||||||
|
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||||
|
|
||||||
|
// OpenID: "profile" scope
|
||||||
|
name: z.string().optional(),
|
||||||
|
family_name: z.string().optional(),
|
||||||
|
given_name: z.string().optional(),
|
||||||
|
middle_name: z.string().optional(),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
preferred_username: z.string().optional(),
|
||||||
|
gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values
|
||||||
|
picture: z.string().url().optional(),
|
||||||
|
profile: z.string().url().optional(),
|
||||||
|
website: z.string().url().optional(),
|
||||||
|
birthdate: z
|
||||||
|
.string()
|
||||||
|
.regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD
|
||||||
|
.optional(),
|
||||||
|
zoneinfo: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Za-z0-9_/]+$/)
|
||||||
|
.optional(),
|
||||||
|
locale: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
||||||
|
.optional(),
|
||||||
|
updated_at: z.number().int().optional(),
|
||||||
|
|
||||||
|
// OpenID: "email" scope
|
||||||
|
email: z.string().optional(),
|
||||||
|
email_verified: z.boolean().optional(),
|
||||||
|
|
||||||
|
// OpenID: "phone" scope
|
||||||
|
phone_number: z.string().optional(),
|
||||||
|
phone_number_verified: z.boolean().optional(),
|
||||||
|
|
||||||
|
// OpenID: "address" scope
|
||||||
|
// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim
|
||||||
|
address: z
|
||||||
|
.object({
|
||||||
|
formatted: z.string().optional(),
|
||||||
|
street_address: z.string().optional(),
|
||||||
|
locality: z.string().optional(),
|
||||||
|
region: z.string().optional(),
|
||||||
|
postal_code: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9396#section-14.2
|
||||||
|
authorization_details: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string(),
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc9396#section-2.2
|
||||||
|
locations: z.array(z.string()).optional(),
|
||||||
|
actions: z.array(z.string()).optional(),
|
||||||
|
datatypes: z.array(z.string()).optional(),
|
||||||
|
identifier: z.string().optional(),
|
||||||
|
privileges: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type JwtPayload = z.infer<typeof jwtPayloadSchema>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { jwkAlgorithms } from './alg.js'
|
||||||
|
import { Jwk, jwkSchema } from './jwk.js'
|
||||||
|
import { VerifyOptions, VerifyPayload, VerifyResult } from './jwt-verify.js'
|
||||||
|
import { Jwt, JwtHeader, JwtPayload } from './jwt.js'
|
||||||
|
import { cachedGetter } from './util.js'
|
||||||
|
|
||||||
|
export abstract class Key {
|
||||||
|
constructor(protected jwk: Jwk) {
|
||||||
|
// A key should always be used either for signing or encryption.
|
||||||
|
if (!jwk.use) throw new TypeError('Missing "use" Parameter value')
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPrivate(): boolean {
|
||||||
|
const { jwk } = this
|
||||||
|
if ('d' in jwk && jwk.d !== undefined) return true
|
||||||
|
return this.isSymetric
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSymetric(): boolean {
|
||||||
|
const { jwk } = this
|
||||||
|
if ('k' in jwk && jwk.k !== undefined) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
get privateJwk(): Jwk | undefined {
|
||||||
|
return this.isPrivate ? this.jwk : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
@cachedGetter
|
||||||
|
get publicJwk(): Jwk | undefined {
|
||||||
|
if (this.isSymetric) return undefined
|
||||||
|
if (this.isPrivate) {
|
||||||
|
const { d: _, ...jwk } = this.jwk as any
|
||||||
|
return jwk
|
||||||
|
}
|
||||||
|
return this.jwk
|
||||||
|
}
|
||||||
|
|
||||||
|
@cachedGetter
|
||||||
|
get bareJwk(): Jwk | undefined {
|
||||||
|
if (this.isSymetric) return undefined
|
||||||
|
const { kty, crv, e, n, x, y } = this.jwk as any
|
||||||
|
return jwkSchema.parse({ crv, e, kty, n, x, y })
|
||||||
|
}
|
||||||
|
|
||||||
|
get use() {
|
||||||
|
return this.jwk.use!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (forced) algorithm to use. If not provided, the key will be usable with
|
||||||
|
* any of the algorithms in {@link algorithms}.
|
||||||
|
*/
|
||||||
|
get alg() {
|
||||||
|
return this.jwk.alg
|
||||||
|
}
|
||||||
|
|
||||||
|
get kid() {
|
||||||
|
return this.jwk.kid
|
||||||
|
}
|
||||||
|
|
||||||
|
get crv() {
|
||||||
|
return (this.jwk as undefined | Extract<Jwk, { crv: unknown }>)?.crv
|
||||||
|
}
|
||||||
|
|
||||||
|
get canVerify() {
|
||||||
|
return this.use === 'sig'
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSign() {
|
||||||
|
return this.use === 'sig' && this.isPrivate && !this.isSymetric
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the algorithms that this key can be used with. If `alg` is provided,
|
||||||
|
* this set will only contain that algorithm.
|
||||||
|
*/
|
||||||
|
@cachedGetter
|
||||||
|
get algorithms(): readonly string[] {
|
||||||
|
return Array.from(jwkAlgorithms(this.jwk))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed JWT
|
||||||
|
*/
|
||||||
|
abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise<Jwt>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the signature, headers and payload of a JWT
|
||||||
|
*/
|
||||||
|
abstract verifyJwt<
|
||||||
|
P extends VerifyPayload = JwtPayload,
|
||||||
|
C extends string = string,
|
||||||
|
>(token: Jwt, options?: VerifyOptions<C>): Promise<VerifyResult<P, C>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { Jwk } from './jwk.js'
|
||||||
|
import { Jwks } from './jwks.js'
|
||||||
|
import { unsafeDecodeJwt } from './jwt-decode.js'
|
||||||
|
import { VerifyOptions } from './jwt-verify.js'
|
||||||
|
import { Jwt, JwtHeader, JwtPayload } from './jwt.js'
|
||||||
|
import { Key } from './key.js'
|
||||||
|
import {
|
||||||
|
Override,
|
||||||
|
cachedGetter,
|
||||||
|
isDefined,
|
||||||
|
matchesAny,
|
||||||
|
preferredOrderCmp,
|
||||||
|
} from './util.js'
|
||||||
|
|
||||||
|
export type JwtSignHeader = Override<JwtHeader, Pick<KeySearch, 'alg' | 'kid'>>
|
||||||
|
|
||||||
|
export type JwtPayloadGetter<P = JwtPayload> = (
|
||||||
|
header: JwtHeader,
|
||||||
|
key: Key,
|
||||||
|
) => P | PromiseLike<P>
|
||||||
|
|
||||||
|
export type KeySearch = {
|
||||||
|
use?: 'sig' | 'enc'
|
||||||
|
kid?: string | string[]
|
||||||
|
alg?: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk
|
||||||
|
const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk
|
||||||
|
|
||||||
|
export class Keyset<K extends Key = Key> implements Iterable<K> {
|
||||||
|
constructor(
|
||||||
|
private readonly keys: readonly K[],
|
||||||
|
/**
|
||||||
|
* The preferred algorithms to use when signing a JWT using this keyset.
|
||||||
|
*/
|
||||||
|
readonly preferredSigningAlgorithms: readonly string[] = [
|
||||||
|
'EdDSA',
|
||||||
|
'ES256K',
|
||||||
|
'ES256',
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7518#section-3.5
|
||||||
|
'PS256',
|
||||||
|
'PS384',
|
||||||
|
'PS512',
|
||||||
|
'HS256',
|
||||||
|
'HS384',
|
||||||
|
'HS512',
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
if (!keys.length) throw new Error('Keyset is empty')
|
||||||
|
|
||||||
|
const kids = new Set<string>()
|
||||||
|
for (const { kid } of keys) {
|
||||||
|
if (!kid) continue
|
||||||
|
|
||||||
|
if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`)
|
||||||
|
else kids.add(kid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@cachedGetter
|
||||||
|
get signAlgorithms(): readonly string[] {
|
||||||
|
const algorithms = new Set<string>()
|
||||||
|
for (const key of this) {
|
||||||
|
if (key.use !== 'sig') continue
|
||||||
|
for (const alg of key.algorithms) {
|
||||||
|
algorithms.add(alg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(
|
||||||
|
[...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@cachedGetter
|
||||||
|
get publicJwks(): Jwks {
|
||||||
|
return {
|
||||||
|
keys: Array.from(this, extractPublicJwk).filter(isDefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@cachedGetter
|
||||||
|
get privateJwks(): Jwks {
|
||||||
|
return {
|
||||||
|
keys: Array.from(this, extractPrivateJwk).filter(isDefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has(kid: string): boolean {
|
||||||
|
return this.keys.some((key) => key.kid === kid)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(search: KeySearch): K {
|
||||||
|
for (const key of this.list(search)) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError(
|
||||||
|
`Key not found ${search.kid || search.alg || '<unknown>'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
*list(search: KeySearch): Generator<K> {
|
||||||
|
// Optimization: Empty string or empty array will not match any key
|
||||||
|
if (search.kid?.length === 0) return
|
||||||
|
if (search.alg?.length === 0) return
|
||||||
|
|
||||||
|
for (const key of this) {
|
||||||
|
if (search.use && key.use !== search.use) continue
|
||||||
|
|
||||||
|
if (Array.isArray(search.kid)) {
|
||||||
|
if (!key.kid || !search.kid.includes(key.kid)) continue
|
||||||
|
} else if (search.kid) {
|
||||||
|
if (key.kid !== search.kid) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(search.alg)) {
|
||||||
|
if (!search.alg.some((a) => key.algorithms.includes(a))) continue
|
||||||
|
} else if (typeof search.alg === 'string') {
|
||||||
|
if (!key.algorithms.includes(search.alg)) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
yield key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findSigningKey(search: Omit<KeySearch, 'use'>): [key: Key, alg: string] {
|
||||||
|
const { kid, alg } = search
|
||||||
|
const matchingKeys: Key[] = []
|
||||||
|
|
||||||
|
for (const key of this.list({ kid, alg, use: 'sig' })) {
|
||||||
|
// Not a signing key
|
||||||
|
if (!key.canSign) continue
|
||||||
|
|
||||||
|
// Skip negotiation if a specific "alg" was provided
|
||||||
|
if (typeof alg === 'string') return [key, alg]
|
||||||
|
|
||||||
|
matchingKeys.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowedAlg = matchesAny(alg)
|
||||||
|
const candidates = matchingKeys.map(
|
||||||
|
(key) => [key, key.algorithms.filter(isAllowedAlg)] as const,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return the first candidates that matches the preferred algorithms
|
||||||
|
for (const prefAlg of this.preferredSigningAlgorithms) {
|
||||||
|
for (const [matchingKey, matchingAlgs] of candidates) {
|
||||||
|
if (matchingAlgs.includes(prefAlg)) return [matchingKey, prefAlg]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return any candidate
|
||||||
|
for (const [matchingKey, matchingAlgs] of candidates) {
|
||||||
|
for (const alg of matchingAlgs) {
|
||||||
|
return [matchingKey, alg]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError(`No singing key found for ${kid || alg || '<unknown>'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator](): IterableIterator<K> {
|
||||||
|
return this.keys.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
async sign(
|
||||||
|
{ alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader,
|
||||||
|
payload: JwtPayload | JwtPayloadGetter,
|
||||||
|
) {
|
||||||
|
const [key, alg] = this.findSigningKey({ alg: searchAlg, kid: searchKid })
|
||||||
|
const protectedHeader = { ...header, alg, kid: key.kid }
|
||||||
|
|
||||||
|
if (typeof payload === 'function') {
|
||||||
|
payload = await payload(protectedHeader, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.createJwt(protectedHeader, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify<
|
||||||
|
P extends Record<string, unknown> = JwtPayload,
|
||||||
|
C extends string = string,
|
||||||
|
>(token: Jwt, options?: VerifyOptions<C>) {
|
||||||
|
const { header } = unsafeDecodeJwt(token)
|
||||||
|
const { kid, alg } = header
|
||||||
|
|
||||||
|
const errors: unknown[] = []
|
||||||
|
|
||||||
|
for (const key of this.list({ use: 'sig', kid, alg })) {
|
||||||
|
try {
|
||||||
|
return await key.verifyJwt<P, C>(token, options)
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AggregateError(errors, 'Unable to verify signature')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||||
|
export type Override<T, V> = Simplify<V & Omit<T, keyof V>>
|
||||||
|
|
||||||
|
export type RequiredKey<T, K extends string> = Simplify<
|
||||||
|
string extends K
|
||||||
|
? T
|
||||||
|
: {
|
||||||
|
[L in K]: Exclude<L extends keyof T ? T[L] : unknown, undefined>
|
||||||
|
} & Omit<T, K>
|
||||||
|
>
|
||||||
|
|
||||||
|
export const isDefined = <T>(i: T | undefined): i is T => i !== undefined
|
||||||
|
|
||||||
|
export const preferredOrderCmp =
|
||||||
|
<T>(order: readonly T[]) =>
|
||||||
|
(a: T, b: T) => {
|
||||||
|
const aIdx = order.indexOf(a)
|
||||||
|
const bIdx = order.indexOf(b)
|
||||||
|
if (aIdx === bIdx) return 0
|
||||||
|
if (aIdx === -1) return 1
|
||||||
|
if (bIdx === -1) return -1
|
||||||
|
return aIdx - bIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesAny<T extends string | number | symbol | boolean>(
|
||||||
|
value: null | undefined | T | readonly T[],
|
||||||
|
): (v: unknown) => v is T {
|
||||||
|
return value == null
|
||||||
|
? (v): v is T => true
|
||||||
|
: Array.isArray(value)
|
||||||
|
? (v): v is T => value.includes(v)
|
||||||
|
: (v): v is T => v === value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to cache the result of a getter on a class instance.
|
||||||
|
*/
|
||||||
|
export const cachedGetter = <T extends object, V>(
|
||||||
|
target: (this: T) => V,
|
||||||
|
_context: ClassGetterDecoratorContext<T, V>,
|
||||||
|
) => {
|
||||||
|
return function (this: T) {
|
||||||
|
const value = target.call(this)
|
||||||
|
Object.defineProperty(this, target.name, {
|
||||||
|
get: () => value,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decoder = new TextDecoder()
|
||||||
|
export const ui8ToString = (value: Uint8Array) => decoder.decode(value)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "hooks",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/social-app.git"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -22275,7 +22275,12 @@ zod@3.21.4:
|
|||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
||||||
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
|
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
|
||||||
|
|
||||||
zod@^3.14.2, zod@^3.20.2, zod@^3.21.4:
|
zod@^3.14.2, zod@^3.21.4:
|
||||||
version "3.22.2"
|
version "3.22.2"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
|
||||||
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
|
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
|
||||||
|
|
||||||
|
zod@^3.22.4:
|
||||||
|
version "3.22.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||||
|
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
|
||||||
|
|||||||
Reference in New Issue
Block a user