@atproto/oauth-provider 0.19.6 → 0.19.8
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/CHANGELOG.md +33 -0
- package/dist/errors/error-parser.js +5 -5
- package/dist/errors/error-parser.js.map +1 -1
- package/package.json +22 -18
- package/src/access-token/access-token-mode.ts +0 -4
- package/src/account/account-manager.ts +0 -591
- package/src/account/account-store.ts +0 -305
- package/src/account/sign-in-data.ts +0 -15
- package/src/account/sign-up-input.ts +0 -20
- package/src/client/client-auth.ts +0 -64
- package/src/client/client-data.ts +0 -9
- package/src/client/client-id.ts +0 -4
- package/src/client/client-info.ts +0 -13
- package/src/client/client-manager.ts +0 -772
- package/src/client/client-store.ts +0 -35
- package/src/client/client-utils.ts +0 -37
- package/src/client/client.ts +0 -394
- package/src/constants.ts +0 -80
- package/src/customization/branding.ts +0 -12
- package/src/customization/build-customization-css.ts +0 -55
- package/src/customization/build-customization-data.ts +0 -24
- package/src/customization/colors.ts +0 -48
- package/src/customization/customization.ts +0 -29
- package/src/customization/links.ts +0 -10
- package/src/device/device-data.ts +0 -11
- package/src/device/device-id.ts +0 -27
- package/src/device/device-manager.ts +0 -292
- package/src/device/device-store.ts +0 -31
- package/src/device/session-id.ts +0 -21
- package/src/dpop/dpop-manager.ts +0 -214
- package/src/dpop/dpop-nonce.ts +0 -121
- package/src/dpop/dpop-proof.ts +0 -6
- package/src/errors/access-denied-error.ts +0 -12
- package/src/errors/account-selection-required-error.ts +0 -12
- package/src/errors/authorization-error.ts +0 -45
- package/src/errors/consent-required-error.ts +0 -12
- package/src/errors/error-parser.ts +0 -147
- package/src/errors/handle-unavailable-error.ts +0 -23
- package/src/errors/invalid-authorization-details-error.ts +0 -27
- package/src/errors/invalid-client-error.ts +0 -20
- package/src/errors/invalid-client-id-error.ts +0 -31
- package/src/errors/invalid-client-metadata-error.ts +0 -68
- package/src/errors/invalid-credentials-error.ts +0 -29
- package/src/errors/invalid-dpop-key-binding-error.ts +0 -21
- package/src/errors/invalid-dpop-proof-error.ts +0 -13
- package/src/errors/invalid-grant-error.ts +0 -21
- package/src/errors/invalid-invite-code-error.ts +0 -10
- package/src/errors/invalid-redirect-uri-error.ts +0 -17
- package/src/errors/invalid-request-error.ts +0 -32
- package/src/errors/invalid-scope-error.ts +0 -15
- package/src/errors/invalid-token-error.ts +0 -60
- package/src/errors/login-required-error.ts +0 -12
- package/src/errors/oauth-error.ts +0 -28
- package/src/errors/second-authentication-factor-required-error.ts +0 -25
- package/src/errors/unauthorized-client-error.ts +0 -20
- package/src/errors/use-dpop-nonce-error.ts +0 -32
- package/src/errors/www-authenticate-error.ts +0 -64
- package/src/index.ts +0 -16
- package/src/lexicon/lexicon-data.ts +0 -11
- package/src/lexicon/lexicon-getter.ts +0 -55
- package/src/lexicon/lexicon-manager.ts +0 -116
- package/src/lexicon/lexicon-store.ts +0 -35
- package/src/lib/csp/index.ts +0 -101
- package/src/lib/hcaptcha.ts +0 -228
- package/src/lib/html/README.md +0 -9
- package/src/lib/html/build-document.ts +0 -151
- package/src/lib/html/escapers.ts +0 -66
- package/src/lib/html/html.ts +0 -56
- package/src/lib/html/hydration-data.ts +0 -19
- package/src/lib/html/index.ts +0 -5
- package/src/lib/html/tags.ts +0 -67
- package/src/lib/html/util.ts +0 -17
- package/src/lib/http/README.md +0 -11
- package/src/lib/http/accept.ts +0 -90
- package/src/lib/http/context.ts +0 -42
- package/src/lib/http/headers.ts +0 -15
- package/src/lib/http/index.ts +0 -10
- package/src/lib/http/method.ts +0 -18
- package/src/lib/http/middleware.ts +0 -169
- package/src/lib/http/parser.ts +0 -101
- package/src/lib/http/path.ts +0 -82
- package/src/lib/http/request.ts +0 -256
- package/src/lib/http/response.ts +0 -122
- package/src/lib/http/route.ts +0 -60
- package/src/lib/http/router.ts +0 -117
- package/src/lib/http/security-headers.ts +0 -100
- package/src/lib/http/stream.ts +0 -57
- package/src/lib/http/types.ts +0 -16
- package/src/lib/http/url.ts +0 -23
- package/src/lib/nsid.ts +0 -10
- package/src/lib/redis.ts +0 -25
- package/src/lib/util/authorization-header.ts +0 -28
- package/src/lib/util/cast.ts +0 -18
- package/src/lib/util/color.ts +0 -168
- package/src/lib/util/crypto.ts +0 -32
- package/src/lib/util/date.ts +0 -7
- package/src/lib/util/error.ts +0 -7
- package/src/lib/util/function.ts +0 -39
- package/src/lib/util/locale.ts +0 -12
- package/src/lib/util/object.ts +0 -23
- package/src/lib/util/redirect-uri.ts +0 -46
- package/src/lib/util/time.ts +0 -49
- package/src/lib/util/type.ts +0 -182
- package/src/lib/util/ui8.ts +0 -14
- package/src/lib/util/well-known.ts +0 -8
- package/src/lib/util/zod-error.ts +0 -26
- package/src/lib/write-form-redirect.ts +0 -58
- package/src/lib/write-html.ts +0 -70
- package/src/metadata/build-metadata.ts +0 -141
- package/src/oauth-client.ts +0 -3
- package/src/oauth-dpop.ts +0 -2
- package/src/oauth-errors.ts +0 -26
- package/src/oauth-hooks.ts +0 -519
- package/src/oauth-middleware.ts +0 -53
- package/src/oauth-provider.ts +0 -1110
- package/src/oauth-store.ts +0 -12
- package/src/oauth-verifier.ts +0 -260
- package/src/replay/replay-manager.ts +0 -51
- package/src/replay/replay-store-memory.ts +0 -36
- package/src/replay/replay-store-redis.ts +0 -30
- package/src/replay/replay-store.ts +0 -44
- package/src/request/code.ts +0 -23
- package/src/request/request-data.ts +0 -36
- package/src/request/request-id.ts +0 -22
- package/src/request/request-manager.ts +0 -521
- package/src/request/request-store.ts +0 -60
- package/src/request/request-uri.ts +0 -42
- package/src/result/authorization-redirect-parameters.ts +0 -24
- package/src/result/authorization-result-authorize-page.ts +0 -16
- package/src/result/authorization-result-redirect.ts +0 -8
- package/src/router/assets/assets-manifest.ts +0 -117
- package/src/router/assets/assets.ts +0 -124
- package/src/router/assets/csrf.ts +0 -79
- package/src/router/assets/send-account-page.ts +0 -23
- package/src/router/assets/send-authorization-page.ts +0 -42
- package/src/router/assets/send-cookie-error-page.ts +0 -21
- package/src/router/assets/send-error-page.ts +0 -25
- package/src/router/assets/send-redirect.ts +0 -140
- package/src/router/create-account-page-middleware.ts +0 -88
- package/src/router/create-api-middleware.ts +0 -1051
- package/src/router/create-authorization-page-middleware.ts +0 -281
- package/src/router/create-oauth-middleware.ts +0 -257
- package/src/router/error-handler.ts +0 -6
- package/src/router/middleware-options.ts +0 -9
- package/src/signer/access-token-payload.ts +0 -25
- package/src/signer/api-token-payload.ts +0 -18
- package/src/signer/signer.ts +0 -121
- package/src/token/refresh-token.ts +0 -30
- package/src/token/token-claims.ts +0 -22
- package/src/token/token-data.ts +0 -40
- package/src/token/token-id.ts +0 -25
- package/src/token/token-manager.ts +0 -427
- package/src/token/token-store.ts +0 -88
- package/src/types/authorization-response-error.ts +0 -27
- package/src/types/color-hue.ts +0 -3
- package/src/types/email-otp.ts +0 -3
- package/src/types/email.ts +0 -26
- package/src/types/handle.ts +0 -23
- package/src/types/invite-code.ts +0 -4
- package/src/types/par-response-error.ts +0 -25
- package/src/types/password.ts +0 -4
- package/src/types/rgb-color.ts +0 -21
- package/tsconfig.build.json +0 -8
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -4
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { hcaptchaConfigSchema } from '../lib/hcaptcha.js'
|
|
3
|
-
import { brandingSchema } from './branding.js'
|
|
4
|
-
|
|
5
|
-
export const customizationSchema = z.object({
|
|
6
|
-
/**
|
|
7
|
-
* Available user domains that can be used to sign up. A non-empty array
|
|
8
|
-
* is required to enable the sign-up feature.
|
|
9
|
-
*/
|
|
10
|
-
availableUserDomains: z.array(z.string()).optional(),
|
|
11
|
-
/**
|
|
12
|
-
* UI customizations
|
|
13
|
-
*/
|
|
14
|
-
branding: brandingSchema.optional(),
|
|
15
|
-
/**
|
|
16
|
-
* Is an invite code required to sign up?
|
|
17
|
-
*/
|
|
18
|
-
inviteCodeRequired: z.boolean().optional(),
|
|
19
|
-
/**
|
|
20
|
-
* Show a warning about 2FA being disabled when updating email address
|
|
21
|
-
*/
|
|
22
|
-
show2FaWarningOnEmailUpdate: z.boolean().optional(),
|
|
23
|
-
/**
|
|
24
|
-
* Enables hCaptcha during sign-up.
|
|
25
|
-
*/
|
|
26
|
-
hcaptcha: hcaptchaConfigSchema.optional(),
|
|
27
|
-
})
|
|
28
|
-
export type CustomizationInput = z.input<typeof customizationSchema>
|
|
29
|
-
export type Customization = z.infer<typeof customizationSchema>
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { isLinkRel } from '../lib/html/build-document.js'
|
|
3
|
-
import { multiLangStringSchema } from '../lib/util/locale.js'
|
|
4
|
-
|
|
5
|
-
export const linksSchema = z.object({
|
|
6
|
-
title: z.union([z.string(), multiLangStringSchema]),
|
|
7
|
-
href: z.string().url(),
|
|
8
|
-
rel: z.string().refine(isLinkRel, 'Invalid link rel').optional(),
|
|
9
|
-
})
|
|
10
|
-
export type Links = z.infer<typeof linksSchema>
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { sessionIdSchema } from './session-id.js'
|
|
3
|
-
|
|
4
|
-
export const deviceDataSchema = z.object({
|
|
5
|
-
sessionId: sessionIdSchema,
|
|
6
|
-
lastSeenAt: z.date(),
|
|
7
|
-
userAgent: z.string().nullable(),
|
|
8
|
-
ipAddress: z.string(),
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
export type DeviceData = z.infer<typeof deviceDataSchema>
|
package/src/device/device-id.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { DEVICE_ID_BYTES_LENGTH, DEVICE_ID_PREFIX } from '../constants.js'
|
|
3
|
-
import { randomHexId } from '../lib/util/crypto.js'
|
|
4
|
-
|
|
5
|
-
export const DEVICE_ID_LENGTH =
|
|
6
|
-
DEVICE_ID_PREFIX.length + DEVICE_ID_BYTES_LENGTH * 2 // hex encoding
|
|
7
|
-
|
|
8
|
-
export const deviceIdSchema = z
|
|
9
|
-
.string()
|
|
10
|
-
.length(DEVICE_ID_LENGTH)
|
|
11
|
-
.refine(
|
|
12
|
-
(v): v is `${typeof DEVICE_ID_PREFIX}${string}` =>
|
|
13
|
-
v.startsWith(DEVICE_ID_PREFIX),
|
|
14
|
-
{
|
|
15
|
-
message: `Invalid device ID format`,
|
|
16
|
-
},
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
export type DeviceId = z.infer<typeof deviceIdSchema>
|
|
20
|
-
|
|
21
|
-
export function isDeviceId(value: unknown): value is DeviceId {
|
|
22
|
-
return deviceIdSchema.safeParse(value).success
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const generateDeviceId = async (): Promise<DeviceId> => {
|
|
26
|
-
return `${DEVICE_ID_PREFIX}${await randomHexId(DEVICE_ID_BYTES_LENGTH)}`
|
|
27
|
-
}
|
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
-
import { z } from 'zod'
|
|
3
|
-
import { SESSION_FIXATION_MAX_AGE } from '../constants.js'
|
|
4
|
-
import { parseHttpCookies } from '../lib/http/index.js'
|
|
5
|
-
import {
|
|
6
|
-
RequestMetadata,
|
|
7
|
-
extractRequestMetadata,
|
|
8
|
-
setCookie,
|
|
9
|
-
} from '../lib/http/request.js'
|
|
10
|
-
import { DeviceData } from './device-data.js'
|
|
11
|
-
import { DeviceId, deviceIdSchema, generateDeviceId } from './device-id.js'
|
|
12
|
-
import { DeviceStore } from './device-store.js'
|
|
13
|
-
import { generateSessionId, sessionIdSchema } from './session-id.js'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @see {@link https://www.npmjs.com/package/keygrip | Keygrip}
|
|
17
|
-
*/
|
|
18
|
-
export const keygripSchema = z.object({
|
|
19
|
-
sign: z.function().args(z.any()).returns(z.string()),
|
|
20
|
-
verify: z.function().args(z.any(), z.string()).returns(z.boolean()),
|
|
21
|
-
index: z.function().args(z.any(), z.string()).returns(z.number()),
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
export const deviceManagerOptionsSchema = z.object({
|
|
25
|
-
/**
|
|
26
|
-
* Controls whether the IP address is read from the `X-Forwarded-For` header
|
|
27
|
-
* (if `true`), or from the `req.socket.remoteAddress` property (if `false`).
|
|
28
|
-
*/
|
|
29
|
-
trustProxy: z
|
|
30
|
-
.function()
|
|
31
|
-
.args<[addr: z.ZodString, i: z.ZodNumber]>(z.string(), z.number())
|
|
32
|
-
.returns(z.boolean())
|
|
33
|
-
.optional(),
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Amount of time (in ms) after which session IDs will be rotated
|
|
37
|
-
*
|
|
38
|
-
* @default 300e3 // (5 minutes)
|
|
39
|
-
*/
|
|
40
|
-
rotationRate: z.number().default(300e3),
|
|
41
|
-
/**
|
|
42
|
-
* Cookie options
|
|
43
|
-
*/
|
|
44
|
-
cookie: z
|
|
45
|
-
.object({
|
|
46
|
-
keys: keygripSchema.optional(),
|
|
47
|
-
/**
|
|
48
|
-
* Amount of time (in ms) after which the session cookie will expire.
|
|
49
|
-
* If set to `null`, the cookie will be a session cookie (deleted when the
|
|
50
|
-
* browser is closed).
|
|
51
|
-
*
|
|
52
|
-
* @default 10 years
|
|
53
|
-
*/
|
|
54
|
-
age: z
|
|
55
|
-
.number()
|
|
56
|
-
.nullable()
|
|
57
|
-
.default(10 * 365.2 * 24 * 60 * 60e3),
|
|
58
|
-
/**
|
|
59
|
-
* Controls whether the cookie is only sent over HTTPS (if `true`), or also
|
|
60
|
-
* over HTTP (if `false`). This should **NOT** be set to `false` in
|
|
61
|
-
* production.
|
|
62
|
-
*/
|
|
63
|
-
secure: z.boolean().default(true),
|
|
64
|
-
/**
|
|
65
|
-
* Controls whether the cookie is sent along with cross-site requests.
|
|
66
|
-
*
|
|
67
|
-
* @default 'lax'
|
|
68
|
-
*/
|
|
69
|
-
sameSite: z.enum(['lax', 'strict']).default('lax'),
|
|
70
|
-
})
|
|
71
|
-
.default({}),
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
export type DeviceManagerOptions = z.input<typeof deviceManagerOptionsSchema>
|
|
75
|
-
|
|
76
|
-
type CookieValue = {
|
|
77
|
-
deviceId: DeviceId
|
|
78
|
-
sessionId: string
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export type DeviceInfo = {
|
|
82
|
-
deviceId: DeviceId
|
|
83
|
-
deviceMetadata: RequestMetadata
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* This class provides an abstraction for keeping track of DEVICE sessions. It
|
|
88
|
-
* relies on a {@link DeviceStore} to persist session data and a cookie to
|
|
89
|
-
* identify the session.
|
|
90
|
-
*/
|
|
91
|
-
export class DeviceManager {
|
|
92
|
-
private readonly options: z.output<typeof deviceManagerOptionsSchema>
|
|
93
|
-
|
|
94
|
-
constructor(
|
|
95
|
-
private readonly store: DeviceStore,
|
|
96
|
-
options: DeviceManagerOptions = {},
|
|
97
|
-
) {
|
|
98
|
-
this.options = deviceManagerOptionsSchema.parse(options)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
public async hasSession(req: IncomingMessage): Promise<boolean> {
|
|
102
|
-
const cookies = await this.getCookies(req)
|
|
103
|
-
return cookies !== null
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
public async load(
|
|
107
|
-
req: IncomingMessage,
|
|
108
|
-
res: ServerResponse,
|
|
109
|
-
forceRotate = false,
|
|
110
|
-
): Promise<DeviceInfo> {
|
|
111
|
-
const cookie = await this.getCookies(req)
|
|
112
|
-
if (cookie) {
|
|
113
|
-
return this.refresh(
|
|
114
|
-
req,
|
|
115
|
-
res,
|
|
116
|
-
cookie.value,
|
|
117
|
-
forceRotate || cookie.mustRotate,
|
|
118
|
-
)
|
|
119
|
-
} else {
|
|
120
|
-
return this.create(req, res)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private async create(
|
|
125
|
-
req: IncomingMessage,
|
|
126
|
-
res: ServerResponse,
|
|
127
|
-
): Promise<DeviceInfo> {
|
|
128
|
-
const deviceMetadata = this.getRequestMetadata(req)
|
|
129
|
-
|
|
130
|
-
const [deviceId, sessionId] = await Promise.all([
|
|
131
|
-
generateDeviceId(),
|
|
132
|
-
generateSessionId(),
|
|
133
|
-
] as const)
|
|
134
|
-
|
|
135
|
-
await this.store.createDevice(deviceId, {
|
|
136
|
-
sessionId,
|
|
137
|
-
lastSeenAt: new Date(),
|
|
138
|
-
userAgent: deviceMetadata.userAgent ?? null,
|
|
139
|
-
ipAddress: deviceMetadata.ipAddress,
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
await this.setCookies(req, res, { deviceId, sessionId })
|
|
143
|
-
|
|
144
|
-
return { deviceId, deviceMetadata }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private async refresh(
|
|
148
|
-
req: IncomingMessage,
|
|
149
|
-
res: ServerResponse,
|
|
150
|
-
{ deviceId, sessionId }: CookieValue,
|
|
151
|
-
forceRotate = false,
|
|
152
|
-
): Promise<DeviceInfo> {
|
|
153
|
-
const data = await this.store.readDevice(deviceId)
|
|
154
|
-
if (!data) return this.create(req, res)
|
|
155
|
-
|
|
156
|
-
const lastSeenAt = new Date(data.lastSeenAt)
|
|
157
|
-
const age = Date.now() - lastSeenAt.getTime()
|
|
158
|
-
|
|
159
|
-
if (sessionId !== data.sessionId) {
|
|
160
|
-
if (age <= SESSION_FIXATION_MAX_AGE) {
|
|
161
|
-
// The cookie was probably rotated by a concurrent request. Let's
|
|
162
|
-
// update the cookie with the new sessionId.
|
|
163
|
-
forceRotate = true
|
|
164
|
-
} else {
|
|
165
|
-
// Something's wrong. Let's create a new session.
|
|
166
|
-
await this.store.deleteDevice(deviceId)
|
|
167
|
-
return this.create(req, res)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const deviceMetadata = this.getRequestMetadata(req)
|
|
172
|
-
|
|
173
|
-
const shouldRotate =
|
|
174
|
-
forceRotate ||
|
|
175
|
-
deviceMetadata.ipAddress !== data.ipAddress ||
|
|
176
|
-
deviceMetadata.userAgent !== data.userAgent ||
|
|
177
|
-
age > this.options.rotationRate
|
|
178
|
-
|
|
179
|
-
if (shouldRotate) {
|
|
180
|
-
await this.rotate(req, res, deviceId, {
|
|
181
|
-
ipAddress: deviceMetadata.ipAddress,
|
|
182
|
-
userAgent: deviceMetadata.userAgent || data.userAgent,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return { deviceId, deviceMetadata }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private async rotate(
|
|
190
|
-
req: IncomingMessage,
|
|
191
|
-
res: ServerResponse,
|
|
192
|
-
deviceId: DeviceId,
|
|
193
|
-
data?: Partial<Omit<DeviceData, 'sessionId' | 'lastSeenAt'>>,
|
|
194
|
-
): Promise<void> {
|
|
195
|
-
const sessionId = await generateSessionId()
|
|
196
|
-
|
|
197
|
-
await this.store.updateDevice(deviceId, {
|
|
198
|
-
...data,
|
|
199
|
-
sessionId,
|
|
200
|
-
lastSeenAt: new Date(),
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
await this.setCookies(req, res, { deviceId, sessionId })
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private async getCookies(
|
|
207
|
-
req: IncomingMessage,
|
|
208
|
-
): Promise<{ value: CookieValue; mustRotate: boolean } | null> {
|
|
209
|
-
const cookies = parseHttpCookies(req)
|
|
210
|
-
|
|
211
|
-
const device = this.parseCookie(cookies, `dev-id`, deviceIdSchema)
|
|
212
|
-
const session = this.parseCookie(cookies, `ses-id`, sessionIdSchema)
|
|
213
|
-
|
|
214
|
-
const deviceId = device?.value
|
|
215
|
-
const sessionId = session?.value
|
|
216
|
-
|
|
217
|
-
// Silently ignore invalid cookies
|
|
218
|
-
if (!deviceId || !sessionId) {
|
|
219
|
-
// If the device cookie is still present, let's cleanup the DB
|
|
220
|
-
if (deviceId) await this.store.deleteDevice(deviceId)
|
|
221
|
-
|
|
222
|
-
return null
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
value: { deviceId, sessionId },
|
|
227
|
-
mustRotate: device.mustRotate || session.mustRotate,
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private parseCookie<T>(
|
|
232
|
-
cookies: Record<string, string | undefined>,
|
|
233
|
-
name: string,
|
|
234
|
-
schema: z.ZodType<T> | z.ZodEffects<z.ZodTypeAny, T, string>,
|
|
235
|
-
): null | { value: T; mustRotate: boolean } {
|
|
236
|
-
const rawValue = Object.hasOwn(cookies, name) ? cookies[name] : null
|
|
237
|
-
if (!rawValue) return null
|
|
238
|
-
|
|
239
|
-
const result = schema.safeParse(rawValue)
|
|
240
|
-
if (!result.success) return null
|
|
241
|
-
|
|
242
|
-
const value = result.data
|
|
243
|
-
|
|
244
|
-
if (this.options.cookie.keys) {
|
|
245
|
-
const hashName = `${name}:hash`
|
|
246
|
-
|
|
247
|
-
const hash = Object.hasOwn(cookies, hashName) ? cookies[hashName] : null
|
|
248
|
-
if (!hash) return null
|
|
249
|
-
|
|
250
|
-
const idx = this.options.cookie.keys.index(rawValue, hash)
|
|
251
|
-
if (idx < 0) return null
|
|
252
|
-
|
|
253
|
-
return { value, mustRotate: idx !== 0 }
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return { value, mustRotate: false }
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private async setCookies(
|
|
260
|
-
req: IncomingMessage,
|
|
261
|
-
res: ServerResponse,
|
|
262
|
-
{ deviceId, sessionId }: CookieValue,
|
|
263
|
-
) {
|
|
264
|
-
this.writeCookie(res, `dev-id`, deviceId)
|
|
265
|
-
this.writeCookie(res, `ses-id`, sessionId)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private writeCookie(res: ServerResponse, name: string, value?: string) {
|
|
269
|
-
const cookieOptions = {
|
|
270
|
-
maxAge: value
|
|
271
|
-
? this.options.cookie.age == null
|
|
272
|
-
? undefined
|
|
273
|
-
: this.options.cookie.age / 1000
|
|
274
|
-
: 0,
|
|
275
|
-
httpOnly: true,
|
|
276
|
-
path: '/',
|
|
277
|
-
secure: this.options.cookie.secure !== false,
|
|
278
|
-
sameSite: this.options.cookie.sameSite,
|
|
279
|
-
} as const
|
|
280
|
-
|
|
281
|
-
setCookie(res, name, value || '', cookieOptions)
|
|
282
|
-
|
|
283
|
-
if (this.options.cookie.keys) {
|
|
284
|
-
const hash = value ? this.options.cookie.keys.sign(value) : ''
|
|
285
|
-
setCookie(res, `${name}:hash`, hash, cookieOptions)
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
public getRequestMetadata(req: IncomingMessage) {
|
|
290
|
-
return extractRequestMetadata(req, this.options)
|
|
291
|
-
}
|
|
292
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { Awaitable, buildInterfaceChecker } from '../lib/util/type.js'
|
|
2
|
-
import { DeviceData } from './device-data.js'
|
|
3
|
-
import { DeviceId } from './device-id.js'
|
|
4
|
-
|
|
5
|
-
// Export all types needed to implement the DeviceStore interface
|
|
6
|
-
export * from './device-data.js'
|
|
7
|
-
export * from './device-id.js'
|
|
8
|
-
export * from './session-id.js'
|
|
9
|
-
|
|
10
|
-
export type { Awaitable }
|
|
11
|
-
|
|
12
|
-
export interface DeviceStore {
|
|
13
|
-
createDevice(deviceId: DeviceId, data: DeviceData): Awaitable<void>
|
|
14
|
-
readDevice(deviceId: DeviceId): Awaitable<DeviceData | null>
|
|
15
|
-
updateDevice(deviceId: DeviceId, data: Partial<DeviceData>): Awaitable<void>
|
|
16
|
-
deleteDevice(deviceId: DeviceId): Awaitable<void>
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const isDeviceStore = buildInterfaceChecker<DeviceStore>([
|
|
20
|
-
'createDevice',
|
|
21
|
-
'deleteDevice',
|
|
22
|
-
'readDevice',
|
|
23
|
-
'updateDevice',
|
|
24
|
-
])
|
|
25
|
-
|
|
26
|
-
export function asDeviceStore<V>(implementation: V): V & DeviceStore {
|
|
27
|
-
if (!implementation || !isDeviceStore(implementation)) {
|
|
28
|
-
throw new Error('Invalid DeviceStore implementation')
|
|
29
|
-
}
|
|
30
|
-
return implementation
|
|
31
|
-
}
|
package/src/device/session-id.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX } from '../constants.js'
|
|
3
|
-
import { randomHexId } from '../lib/util/crypto.js'
|
|
4
|
-
|
|
5
|
-
export const SESSION_ID_LENGTH =
|
|
6
|
-
SESSION_ID_PREFIX.length + SESSION_ID_BYTES_LENGTH * 2 // hex encoding
|
|
7
|
-
|
|
8
|
-
export const sessionIdSchema = z
|
|
9
|
-
.string()
|
|
10
|
-
.length(SESSION_ID_LENGTH)
|
|
11
|
-
.refine(
|
|
12
|
-
(v): v is `${typeof SESSION_ID_PREFIX}${string}` =>
|
|
13
|
-
v.startsWith(SESSION_ID_PREFIX),
|
|
14
|
-
{
|
|
15
|
-
message: `Invalid session ID format`,
|
|
16
|
-
},
|
|
17
|
-
)
|
|
18
|
-
export type SessionId = z.infer<typeof sessionIdSchema>
|
|
19
|
-
export const generateSessionId = async (): Promise<SessionId> => {
|
|
20
|
-
return `${SESSION_ID_PREFIX}${await randomHexId(SESSION_ID_BYTES_LENGTH)}`
|
|
21
|
-
}
|
package/src/dpop/dpop-manager.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose'
|
|
3
|
-
import { z } from 'zod'
|
|
4
|
-
import { ValidationError } from '@atproto/jwk'
|
|
5
|
-
import { DPOP_NONCE_MAX_AGE } from '../constants.js'
|
|
6
|
-
import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
|
|
7
|
-
import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js'
|
|
8
|
-
import { ifURL } from '../lib/util/cast.js'
|
|
9
|
-
import {
|
|
10
|
-
DpopNonce,
|
|
11
|
-
DpopSecret,
|
|
12
|
-
dpopSecretSchema,
|
|
13
|
-
rotationIntervalSchema,
|
|
14
|
-
} from './dpop-nonce.js'
|
|
15
|
-
import { DpopProof } from './dpop-proof.js'
|
|
16
|
-
|
|
17
|
-
const { JOSEError } = errors
|
|
18
|
-
|
|
19
|
-
export { DpopNonce, type DpopSecret }
|
|
20
|
-
|
|
21
|
-
export const dpopManagerOptionsSchema = z.object({
|
|
22
|
-
/**
|
|
23
|
-
* Set this to `false` to disable the use of nonces in DPoP proofs. Set this
|
|
24
|
-
* to a secret Uint8Array or hex encoded string to use a predictable seed for
|
|
25
|
-
* all nonces (typically useful when multiple instances are running). Leave
|
|
26
|
-
* undefined to generate a random seed at startup.
|
|
27
|
-
*/
|
|
28
|
-
dpopSecret: z.union([z.literal(false), dpopSecretSchema]).optional(),
|
|
29
|
-
dpopRotationInterval: rotationIntervalSchema.optional(),
|
|
30
|
-
})
|
|
31
|
-
export type DpopManagerOptions = z.input<typeof dpopManagerOptionsSchema>
|
|
32
|
-
|
|
33
|
-
export class DpopManager {
|
|
34
|
-
protected readonly dpopNonce?: DpopNonce
|
|
35
|
-
|
|
36
|
-
constructor(options: DpopManagerOptions = {}) {
|
|
37
|
-
const { dpopSecret, dpopRotationInterval } =
|
|
38
|
-
dpopManagerOptionsSchema.parse(options)
|
|
39
|
-
this.dpopNonce =
|
|
40
|
-
dpopSecret === false
|
|
41
|
-
? undefined
|
|
42
|
-
: new DpopNonce(dpopSecret, dpopRotationInterval)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
nextNonce(): string | undefined {
|
|
46
|
-
return this.dpopNonce?.next()
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
|
|
51
|
-
*/
|
|
52
|
-
async checkProof(
|
|
53
|
-
httpMethod: string,
|
|
54
|
-
httpUrl: Readonly<URL>,
|
|
55
|
-
httpHeaders: Record<string, undefined | string | string[]>,
|
|
56
|
-
accessToken?: string,
|
|
57
|
-
): Promise<null | DpopProof> {
|
|
58
|
-
// Fool proofing against use of empty string
|
|
59
|
-
if (!httpMethod) {
|
|
60
|
-
throw new TypeError('HTTP method is required')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const proof = extractProof(httpHeaders)
|
|
64
|
-
if (!proof) return null
|
|
65
|
-
|
|
66
|
-
const { protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, {
|
|
67
|
-
typ: 'dpop+jwt',
|
|
68
|
-
maxTokenAge: 10, // Will ensure presence & validity of "iat" claim
|
|
69
|
-
clockTolerance: DPOP_NONCE_MAX_AGE / 1e3,
|
|
70
|
-
}).catch((err) => {
|
|
71
|
-
throw wrapInvalidDpopProofError(err, 'Failed to verify DPoP proof')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
// @NOTE For legacy & backwards compatibility reason, we cannot use
|
|
75
|
-
// `jwtPayloadSchema` here as it will reject DPoP proofs containing a query
|
|
76
|
-
// or fragment component in the "htu" claim.
|
|
77
|
-
|
|
78
|
-
// const { ath, htm, htu, jti, nonce } = await jwtPayloadSchema
|
|
79
|
-
// .parseAsync(payload)
|
|
80
|
-
// .catch((err) => {
|
|
81
|
-
// throw buildInvalidDpopProofError('Invalid DPoP proof', err)
|
|
82
|
-
// })
|
|
83
|
-
|
|
84
|
-
// @TODO Uncomment previous lines (and remove redundant checks bellow) once
|
|
85
|
-
// we decide to drop legacy support.
|
|
86
|
-
const { ath, htm, htu, jti, nonce } = payload
|
|
87
|
-
|
|
88
|
-
if (nonce !== undefined && typeof nonce !== 'string') {
|
|
89
|
-
throw new InvalidDpopProofError('Invalid DPoP "nonce" type')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!jti || typeof jti !== 'string') {
|
|
93
|
-
throw new InvalidDpopProofError('DPoP "jti" missing')
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Note rfc9110#section-9.1 states that the method name is case-sensitive
|
|
97
|
-
if (!htm || htm !== httpMethod) {
|
|
98
|
-
throw new InvalidDpopProofError('DPoP "htm" mismatch')
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (!htu || typeof htu !== 'string') {
|
|
102
|
-
throw new InvalidDpopProofError('Invalid DPoP "htu" type')
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// > To reduce the likelihood of false negatives, servers SHOULD employ
|
|
106
|
-
// > syntax-based normalization (Section 6.2.2 of [RFC3986]) and
|
|
107
|
-
// > scheme-based normalization (Section 6.2.3 of [RFC3986]) before
|
|
108
|
-
// > comparing the htu claim.
|
|
109
|
-
//
|
|
110
|
-
// RFC9449 section 4.3. Checking DPoP Proofs - https://datatracker.ietf.org/doc/html/rfc9449#section-4.3
|
|
111
|
-
if (!htu || parseHtu(htu) !== normalizeHtuUrl(httpUrl)) {
|
|
112
|
-
throw new InvalidDpopProofError('DPoP "htu" mismatch')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (!nonce && this.dpopNonce) {
|
|
116
|
-
throw new UseDpopNonceError()
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (nonce && !this.dpopNonce?.check(nonce)) {
|
|
120
|
-
throw new UseDpopNonceError('DPoP "nonce" mismatch')
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (accessToken) {
|
|
124
|
-
const accessTokenHash = createHash('sha256').update(accessToken).digest()
|
|
125
|
-
if (ath !== accessTokenHash.toString('base64url')) {
|
|
126
|
-
throw new InvalidDpopProofError('DPoP "ath" mismatch')
|
|
127
|
-
}
|
|
128
|
-
} else if (ath !== undefined) {
|
|
129
|
-
throw new InvalidDpopProofError('DPoP "ath" claim not allowed')
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// @NOTE we can assert there is a jwk because the jwtVerify used the
|
|
133
|
-
// EmbeddedJWK key getter mechanism.
|
|
134
|
-
const jwk = protectedHeader.jwk!
|
|
135
|
-
const jkt = await calculateJwkThumbprint(jwk, 'sha256').catch((err) => {
|
|
136
|
-
throw wrapInvalidDpopProofError(err, 'Failed to calculate jkt')
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// @NOTE We freeze the proof to prevent accidental modification (esp. from
|
|
140
|
-
// hooks).
|
|
141
|
-
return Object.freeze({ jti, jkt, htm, htu })
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function extractProof(
|
|
146
|
-
httpHeaders: Record<string, undefined | string | string[]>,
|
|
147
|
-
): string | null {
|
|
148
|
-
const dpopHeader = httpHeaders['dpop']
|
|
149
|
-
switch (typeof dpopHeader) {
|
|
150
|
-
case 'string':
|
|
151
|
-
if (dpopHeader) return dpopHeader
|
|
152
|
-
throw new InvalidDpopProofError('DPoP header cannot be empty')
|
|
153
|
-
case 'object':
|
|
154
|
-
// @NOTE the "0" case should never happen a node.js HTTP server will only
|
|
155
|
-
// return an array if the header is set multiple times.
|
|
156
|
-
if (dpopHeader.length === 1 && dpopHeader[0]) return dpopHeader[0]!
|
|
157
|
-
throw new InvalidDpopProofError('DPoP header must contain a single proof')
|
|
158
|
-
default:
|
|
159
|
-
return null
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Constructs the HTTP URI (htu) claim as defined in RFC9449.
|
|
165
|
-
*
|
|
166
|
-
* The htu claim is the normalized URL of the HTTP request, excluding the query
|
|
167
|
-
* string and fragment. This function ensures that the URL is normalized by
|
|
168
|
-
* removing the search and hash components, as well as by using an URL object to
|
|
169
|
-
* simplify the pathname (e.g. removing dot segments).
|
|
170
|
-
*
|
|
171
|
-
* @returns The normalized URL as a string.
|
|
172
|
-
* @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
|
|
173
|
-
*/
|
|
174
|
-
function normalizeHtuUrl(url: Readonly<URL>): string {
|
|
175
|
-
// NodeJS's `URL` normalizes the pathname, so we can just use that.
|
|
176
|
-
return url.origin + url.pathname
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function parseHtu(htu: string): string {
|
|
180
|
-
const url = ifURL(htu)
|
|
181
|
-
if (!url) {
|
|
182
|
-
throw new InvalidDpopProofError('DPoP "htu" is not a valid URL')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// @NOTE the checks bellow can be removed once once jwtPayloadSchema is used
|
|
186
|
-
// to validate the DPoP proof payload as it already performs these checks
|
|
187
|
-
// (though the htuSchema).
|
|
188
|
-
|
|
189
|
-
if (url.password || url.username) {
|
|
190
|
-
throw new InvalidDpopProofError('DPoP "htu" must not contain credentials')
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
194
|
-
throw new InvalidDpopProofError('DPoP "htu" must be http or https')
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// @NOTE For legacy & backwards compatibility reason, we allow a query and
|
|
198
|
-
// fragment in the DPoP proof's htu. This is not a standard behavior as the
|
|
199
|
-
// htu is not supposed to contain query or fragment.
|
|
200
|
-
|
|
201
|
-
// NodeJS's `URL` normalizes the pathname.
|
|
202
|
-
return normalizeHtuUrl(url)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function wrapInvalidDpopProofError(
|
|
206
|
-
err: unknown,
|
|
207
|
-
title: string,
|
|
208
|
-
): InvalidDpopProofError {
|
|
209
|
-
const msg =
|
|
210
|
-
err instanceof JOSEError || err instanceof ValidationError
|
|
211
|
-
? `${title}: ${err.message}`
|
|
212
|
-
: title
|
|
213
|
-
return new InvalidDpopProofError(msg, err)
|
|
214
|
-
}
|