🚧 OAuth2 - Authorization Server (#2482)

* chore(deps): update zod

* chore(deps): update pino to match entryway version

* chore(tsconfig): remove truncation of types through noErrorTruncation

* add support for DPoP token type when logging

* fix(bsky): JSON.parse does not return value of type JSON

* fix(pds): add res property to ReqCtx

* fix(pds): properly type getPreferences return value

* chore(tsconfig): disable noFallthroughCasesInSwitch

* refactor(pds): move tracer config in own file

* feat(dev-env): start with "pnpm dev"

* feat(oauth): add oauth provider & client libs

* feat(pds): add oauth provider

* chore: changeset

* feat: various fixes and improvements

* chore(deps): update better-sqlite3 to version 10.0.0 for node 22 compatibility

* chore(deps): drop unused tslib

* fix(did): normalize service IDs before looking for duplicates

* fix(did): avoid minor type casting

* fix(did): improve argument validation

* fix(fetch): explicit use of negation around number comparison

* fix(oauth-provider): improve argument validation

* feat(did): add ATPROTO specific "isAtprotoDidWeb" method

* feat(rollup-plugin-bundle-manifest): add readme

* feat(lint): add eqeqeq rule (only allow == and != with null)

* fix(oauth-client-browser): typo in gitignore

* fix(oauth-provider): properly name error class file

* fix(oauth-provider): remove un-necessary useMemo

* fix(did-resolver): properly build did:web document url

* fix(did-resolver): remove unused types

* fix(fetch): remove unused utils

* fix(pds): remove unused script and dependency

* fix(oauth-provider): simplify isSubPath util

* fix(oauth-provider): add InvalidRedirectUriError static constructor

* fix(jwk): improve JWT validation to provide better error messages and distinguish between signed and unsigned tokens

* fix(pds): use "debug" log level for fetch method

* fix(pds): allow access tokens to contain an unknown "typ" claim (with the exception of "dpop+jwt")

* fix(jwk): remove un-necessary code

* fix(pds): account for whitespace chars when checking JSON

* fix(pds): remove oauth specific config

* fix(pds): run all write queries through transaction or executeWithRetry
fix(pds): remove outdated comments
fix(pds): rename used_refresh_token columns & added primary key
fix(pds): run cleanup task through backgroundQueue
fix(pds): add device.id foreign key to device_account
fix(pds): add comment on cleanup of used_refresh_token
fix(pds): add primary key on device_account

* fix(oauth-provider:time): simplify constantTime util

* fix(pds): rename disableSsrf into disableSsrfProtection

* fix(oauth-client-react-native): remove incomplete package

* refactor(pds): remove status & active from ActorAccount

* fix(pds): invalidate all oauth tokens on takedown

* fix(oauth-provider): enforce token expiry

* fix(pds): properly support deactivated accounts

* perf(pds:db): allow transaction function to be sync

* refactor(psq:account-manager): expose only query builders & data transformations utils from helpers

* fix(oauth-provider): imports from self

* fix(ci): add nested packages to build artifacts

* style(fetch): rename TODO into @TODO

* style(rollup-plugin-bundle-manifest): remove "TODO" from comment

* style(oauth-client): rename TODO into @TODO

* style(oauth-provider): rename TODO into @TODO

* refactor(oauth-client): remove "OAuth" prefix from types

* fix(oauth-client-browser): better type SessionListener

* style(oauth): rename TODO into @TODO

* fix(oauth-provider): enforce provider max session age

* fix(oauth-provider): check authentication parameters against all client metadata

* fix(api): tests

* fix(pds): remove .js from imports for tests

* fix(pds): change account status to match tests

* chore(deps): make all packages depend on the same zod version

* fix(common-web): remove un-necessary binding of Checkable to "zod"

* refactor(jwk): infer jwt schema from refinement definition

* fix(handle-resolver): allow resolution errors to propagate
docs(handle-resolver): better handling of DNS resolution errors
fix(handle-resolver): properly handle DOH responses

* fix(did): service endpoint arrays must contain "one or more" element

* refactor(pipe): simplify implementation

* fix(pds): add missing DB indexes

* feat(oauth): Resolve Authorization Server URI through Protected Resource Metadata

* style:(oauth-client): import order

* docs(oauth-provider:redirect-uri): add reference url

* feat(oauth): implement "OAuth Client ID Metadata Document" from draft-parecki-oauth-client-id-metadata-document-latest internet draft

* feat(oauth-client): backport changes from feat-oauth-client

* docs(simple-store): improve comments

* feat(lexicons): add iterable capabilities

* fix(pds): type error in dev mode

* feat(oauth-provider): improved error reporting

* fix(oauth-types): allow insecure issuer during tests

* fix(xrpc-server): allow upload of empty files

* fix: lint

* feat(fetch): keep request reference in errors
feat(fetch): utilities improvements

* fix(pds): allow more than one session token per user

* feat(ozone): improve env validation error messages

* fix(oauth-client): account for DPoP when checking for invalid_token errors

* fixup! feat(fetch): keep request reference in errors feat(fetch): utilities improvements

* fixup! feat(fetch): keep request reference in errors feat(fetch): utilities improvements

* fix(oauth): various validation fixes
feat(oauth): share client_id validation and parsing utilities between client & provider

* feat(dev-env): fix ozone port number

* fix(fetch-node): prevent fetch against invalid domain names

* fix(oauth-provider): add typings for psl dep

* feat(jwk): make type def compatible with TS 4.x

* fix(oauth): fixed various spec compliance
fix(oauth): return "sub" in refresh token response
fix(oauth): limit token validity for third party clients
fix(oauth): hide client image when not trusted

* fix(oauth): lint

* pds: switch changeset to patch, no breaking changes

* changeset and config for new oauth deps

---------

Co-authored-by: Devin Ivy <devinivy@gmail.com>
This commit is contained in:
Matthieu Sieben 2024-06-18 21:11:37 +02:00 committed by GitHub
parent 80dae83540
commit a8d6c11235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
448 changed files with 26164 additions and 520 deletions

View File

@ -0,0 +1,23 @@
---
"@atproto/pds": patch
"@atproto-labs/rollup-plugin-bundle-manifest": minor
"@atproto-labs/handle-resolver-node": minor
"@atproto-labs/simple-store-memory": minor
"@atproto-labs/identity-resolver": minor
"@atproto/oauth-client-browser": minor
"@atproto-labs/handle-resolver": minor
"@atproto-labs/did-resolver": minor
"@atproto-labs/simple-store": minor
"@atproto/oauth-provider": minor
"@atproto-labs/fetch-node": minor
"@atproto/jwk-webcrypto": minor
"@atproto/oauth-client": minor
"@atproto/oauth-types": minor
"@atproto-labs/fetch": minor
"@atproto/jwk-jose": minor
"@atproto-labs/pipe": minor
"@atproto/jwk": minor
"@atproto/did": minor
---
Add OAuth provider capability & support for DPoP signed tokens

View File

@ -18,6 +18,7 @@
"no-var": "error",
"prefer-const": "warn",
"no-misleading-character-class": "warn",
"eqeqeq": ["error", "always", { "null": "ignore" }],
"@typescript-eslint/no-unused-vars": [
"warn",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }

View File

@ -27,7 +27,9 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: dist
path: packages/*/dist
path: |
packages/*/dist
packages/*/*/dist
retention-days: 1
test:
name: Test

4
.gitignore vendored
View File

@ -2,14 +2,14 @@ node_modules
lerna-debug.log
npm-debug.log
yarn-error.log
packages/*/dist
packages/**/dist
.idea
packages/*/coverage
.vscode/
test.sqlite
.DS_Store
*.log
tsconfig.build.tsbuildinfo
*.tsbuildinfo
.*.env
.env
\#*\#

View File

@ -19,7 +19,7 @@
"verify:types": "tsc --build tsconfig.json",
"format": "pnpm lint:fix && pnpm style:fix",
"build": "pnpm --recursive --stream build",
"dev": "pnpm --stream '/^dev:.+$/'",
"dev": "NODE_ENV=development pnpm --stream '/^dev:.+$/'",
"dev:tsc": "tsc --build tsconfig.json --watch",
"dev:pkg": "pnpm --recursive --parallel --stream dev",
"test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test",
@ -51,7 +51,9 @@
},
"workspaces": {
"packages": [
"packages/*"
"packages/*",
"packages/oauth/*",
"packages/internal/*"
]
}
}

View File

@ -13,6 +13,7 @@
- [Crypto](./crypto): Atproto's common cryptographic operations.
- [Syntax](./syntax): A library for identifier syntax: NSID, AT URI, handles, etc.
- [Lexicon](./lexicon): A library for validating data using atproto's schema system.
- [OAuth Provider](./oauth/oauth-provider): A library for supporting ATPROTO's OAuth.
- [Repo](./repo): The "atproto repository" core implementation (a Merkle Search Tree).
- [XRPC](./xrpc): An XRPC client implementation.
- [XRPC Server](./xrpc-server): An XRPC server implementation.

View File

@ -355,7 +355,7 @@ describe('agent', () => {
expect(events.length).toEqual(2)
expect(events[0]).toEqual('create-failed')
expect(events[1]).toEqual('network-error')
expect(events[1]).toEqual('expired')
expect(sessions.length).toEqual(2)
expect(typeof sessions[0]).toEqual('undefined')
expect(typeof sessions[1]).toEqual('undefined')

View File

@ -51,7 +51,7 @@
"multiformats": "^9.9.0",
"p-queue": "^6.6.2",
"pg": "^8.10.0",
"pino": "^8.15.0",
"pino": "^8.21.0",
"pino-http": "^8.2.1",
"sharp": "^0.32.6",
"structured-headers": "^1.0.1",

View File

@ -67,9 +67,7 @@ export const parseRecordBytes = <T>(
return parseJsonBytes(bytes) as T
}
export const parseJsonBytes = (
bytes: Uint8Array | undefined,
): JSON | undefined => {
export const parseJsonBytes = (bytes: Uint8Array | undefined): unknown => {
if (!bytes || bytes.byteLength === 0) return
const parsed = JSON.parse(ui8.toString(bytes, 'utf8'))
return parsed ?? undefined

View File

@ -1,8 +1,6 @@
import pino from 'pino'
import { stdSerializers } from 'pino'
import pinoHttp from 'pino-http'
import * as jose from 'jose'
import { subsystemLogger } from '@atproto/common'
import { parseBasicAuth } from './auth-verifier'
export const dbLogger: ReturnType<typeof subsystemLogger> =
subsystemLogger('bsky:db')
@ -20,40 +18,85 @@ export const httpLogger: ReturnType<typeof subsystemLogger> =
export const loggerMiddleware = pinoHttp({
logger: httpLogger,
serializers: {
err: (err) => {
return {
code: err?.code,
message: err?.message,
}
},
req: (req) => {
const serialized = pino.stdSerializers.req(req)
const authHeader = serialized.headers.authorization || ''
let auth: string | undefined = undefined
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.slice('Bearer '.length)
const { iss } = jose.decodeJwt(token)
if (iss) {
auth = 'Bearer ' + iss
} else {
auth = 'Bearer Invalid'
}
}
if (authHeader.startsWith('Basic ')) {
const parsed = parseBasicAuth(authHeader)
if (!parsed) {
auth = 'Basic Invalid'
} else {
auth = 'Basic ' + parsed.username
}
}
return {
...serialized,
headers: {
...serialized.headers,
authorization: auth,
},
}
},
err: errSerializer,
req: reqSerializer,
},
})
function errSerializer(err: any) {
return {
code: err?.code,
message: err?.message,
}
}
function reqSerializer(req: any) {
const serialized = stdSerializers.req(req)
serialized.headers = obfuscateHeaders(serialized.headers)
return serialized
}
function obfuscateHeaders(headers: Record<string, string>) {
const obfuscatedHeaders: Record<string, string> = {}
for (const key in headers) {
if (key.toLowerCase() === 'authorization') {
obfuscatedHeaders[key] = obfuscateAuthHeader(headers[key])
} else if (key.toLowerCase() === 'dpop') {
obfuscatedHeaders[key] = obfuscateJws(headers[key]) || 'Invalid'
} else {
obfuscatedHeaders[key] = headers[key]
}
}
return obfuscatedHeaders
}
function obfuscateAuthHeader(authHeader: string): string {
// This is a hot path (runs on every request). Avoid using split() or regex.
const spaceIdx = authHeader.indexOf(' ')
if (spaceIdx === -1) return 'Invalid'
const type = authHeader.slice(0, spaceIdx)
switch (type.toLowerCase()) {
case 'bearer':
return `${type} ${obfuscateBearer(authHeader.slice(spaceIdx + 1))}`
case 'dpop':
return `${type} ${obfuscateJws(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
case 'basic':
return `${type} ${obfuscateBasic(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
default:
return `Invalid`
}
}
function obfuscateBasic(token: string): null | string {
if (!token) return null
const buffer = Buffer.from(token, 'base64')
if (!buffer.length) return null // Buffer.from will silently ignore invalid base64 chars
const authHeader = buffer.toString('utf8')
const colIdx = authHeader.indexOf(':')
if (colIdx === -1) return null
const username = authHeader.slice(0, colIdx)
return `${username}:***`
}
function obfuscateBearer(token: string): string {
return obfuscateJws(token) || obfuscateToken(token)
}
function obfuscateToken(token: string): string {
return token ? '***' : ''
}
function obfuscateJws(token: string): null | string {
const firstDot = token.indexOf('.')
if (firstDot === -1) return null
const secondDot = token.indexOf('.', firstDot + 1)
if (secondDot === -1) return null
if (token.indexOf('.', secondDot + 1) !== -1) return null
// Strip the signature
return token.slice(0, secondDot) + '.obfuscated'
}

View File

@ -22,7 +22,7 @@
"graphemer": "^1.4.0",
"multiformats": "^9.9.0",
"uint8arrays": "3.0.0",
"zod": "^3.21.4"
"zod": "^3.23.8"
},
"devDependencies": {
"jest": "^28.1.2"

View File

@ -1,10 +1,11 @@
import { ZodError } from 'zod'
// Explicitly not using "zod" types here to avoid mismatching types due to
// version differences.
export interface Checkable<T> {
parse: (obj: unknown) => T
safeParse: (
obj: unknown,
) => { success: true; data: T } | { success: false; error: ZodError }
) => { success: true; data: T } | { success: false; error: Error }
}
export interface Def<T> {

View File

@ -24,7 +24,7 @@
"cbor-x": "^1.5.1",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"pino": "^8.15.0"
"pino": "^8.21.0"
},
"devDependencies": {
"jest": "^28.1.2",

View File

@ -17,7 +17,8 @@
"bin": "dist/bin.js",
"scripts": {
"build": "tsc --build tsconfig.build.json",
"start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js"
"start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js",
"dev": "../dev-infra/with-test-redis-and-db.sh node --watch dist/bin.js"
},
"dependencies": {
"@atproto/api": "workspace:^",

View File

@ -27,6 +27,7 @@ const run = async () => {
},
plc: { port: 2582 },
ozone: {
port: 2587,
chatUrl: 'http://localhost:2590', // must run separate chat service
chatDid: 'did:example:chat',
},

View File

@ -45,6 +45,16 @@ export class TestPds {
modServiceDid: 'did:example:invalid',
plcRotationKeyK256PrivateKeyHex: plcRotationPriv,
inviteRequired: false,
fetchDisableSsrfProtection: true,
serviceName: 'Development PDS',
primaryColor: '#ffcb1e',
errorColor: undefined,
logoUrl:
'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png',
homeUrl: 'https://bsky.social/',
termsOfServiceUrl: 'https://bsky.social/about/support/tos',
privacyPolicyUrl: 'https://bsky.social/about/support/privacy-policy',
supportUrl: 'https://blueskyweb.zendesk.com/hc/en-us',
...config,
}
const cfg = pds.envToCfg(env)

36
packages/did/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "@atproto/did",
"version": "0.0.1",
"license": "MIT",
"description": "DID resolution and verification library",
"keywords": [
"atproto",
"did",
"validation",
"types"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/did"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,151 @@
import { z } from 'zod'
import { Did, didSchema } from './did.js'
/**
* RFC3968 compliant URI
*
* @see {@link https://www.rfc-editor.org/rfc/rfc3986}
*/
const rfc3968UriSchema = z.string().refine((data) => {
try {
new URL(data)
return true
} catch {
return false
}
}, 'RFC3968 compliant URI')
const didControllerSchema = z.union([didSchema, z.array(didSchema)])
/**
* @note this schema might be too permissive
*/
const didRelativeUriSchema = z.union([
rfc3968UriSchema,
z.string().regex(/^#[^#]+$/),
])
const didVerificationMethodSchema = z.object({
id: didRelativeUriSchema,
type: z.string().min(1),
controller: didControllerSchema,
publicKeyJwk: z.record(z.string(), z.unknown()).optional(),
publicKeyMultibase: z.string().optional(),
})
/**
* The value of the id property MUST be a URI conforming to [RFC3986]. A
* conforming producer MUST NOT produce multiple service entries with the same
* id. A conforming consumer MUST produce an error if it detects multiple
* service entries with the same id.
*
* @note Normally, only rfc3968UriSchema should be allowed here. However, the
* did:plc uses relative URI. For this reason, we also allow relative URIs
* here.
*/
const didServiceIdSchema = didRelativeUriSchema
/**
* The value of the type property MUST be a string or a set of strings. In order
* to maximize interoperability, the service type and its associated properties
* SHOULD be registered in the DID Specification Registries
* [DID-SPEC-REGISTRIES].
*/
const didServiceTypeSchema = z.union([z.string(), z.array(z.string())])
/**
* The value of the serviceEndpoint property MUST be a string, a map, or a set
* composed of one or more strings and/or maps. All string values MUST be valid
* URIs conforming to [RFC3986] and normalized according to the Normalization
* and Comparison rules in RFC3986 and to any normalization rules in its
* applicable URI scheme specification.
*/
const didServiceEndpointSchema = z.union([
rfc3968UriSchema,
z.record(z.string(), rfc3968UriSchema),
z
.array(z.union([rfc3968UriSchema, z.record(z.string(), rfc3968UriSchema)]))
.nonempty(),
])
/**
* Each service map MUST contain id, type, and serviceEndpoint properties.
* @see {@link https://www.w3.org/TR/did-core/#services}
*/
const didServiceSchema = z.object({
id: didServiceIdSchema,
type: didServiceTypeSchema,
serviceEndpoint: didServiceEndpointSchema,
})
export type DidService = z.infer<typeof didServiceSchema>
const didAuthenticationSchema = z.union([
//
didRelativeUriSchema,
didVerificationMethodSchema,
])
/**
* @note This schema is incomplete
* @see {@link https://www.w3.org/TR/did-core/#production-0}
*/
export const didDocumentSchema = z.object({
'@context': z.union([
z.literal('https://www.w3.org/ns/did/v1'),
z
.array(z.string().url())
.nonempty()
.refine((data) => data[0] === 'https://www.w3.org/ns/did/v1', {
message: 'First @context must be https://www.w3.org/ns/did/v1',
}),
]),
id: didSchema,
controller: didControllerSchema.optional(),
alsoKnownAs: z.array(rfc3968UriSchema).optional(),
service: z.array(didServiceSchema).optional(),
authentication: z.array(didAuthenticationSchema).optional(),
verificationMethod: z
.array(z.union([didVerificationMethodSchema, didRelativeUriSchema]))
.optional(),
})
export type DidDocument<Method extends string = string> = z.infer<
typeof didDocumentSchema
> & { id: Did<Method> }
// @TODO: add other refinements ?
export const didDocumentValidator = didDocumentSchema
.superRefine((data, ctx) => {
if (data.service) {
for (let i = 0; i < data.service.length; i++) {
if (data.service[i].id === data.id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Service id must be different from the document id`,
path: ['service', i, 'id'],
})
}
}
}
})
.superRefine((data, ctx) => {
if (data.service) {
const normalizedIds = data.service.map((s) =>
s.id?.startsWith('#') ? `${data.id}${s.id}` : s.id,
)
for (let i = 0; i < normalizedIds.length; i++) {
for (let j = i + 1; j < normalizedIds.length; j++) {
if (normalizedIds[i] === normalizedIds[j]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate service id (${normalizedIds[j]}) found in the document`,
path: ['service', j, 'id'],
})
}
}
}
}
})

View File

@ -0,0 +1,49 @@
export class DidError extends Error {
constructor(
public readonly did: string,
message: string,
public readonly code: string,
public readonly status = 400,
cause?: unknown,
) {
super(message, { cause })
}
/**
* For compatibility with error handlers in common HTTP frameworks.
*/
get statusCode() {
return this.status
}
override toString() {
return `${this.constructor.name} ${this.code} (${this.did}): ${this.message}`
}
static from(cause: unknown, did: string): DidError {
if (cause instanceof DidError) {
return cause
}
const message =
cause instanceof Error
? cause.message
: typeof cause === 'string'
? cause
: 'An unknown error occurred'
const status =
(typeof cause?.['statusCode'] === 'number'
? cause['statusCode']
: undefined) ??
(typeof cause?.['status'] === 'number' ? cause['status'] : undefined)
return new DidError(did, message, 'did-unknown-error', status, cause)
}
}
export class InvalidDidError extends DidError {
constructor(did: string, message: string, cause?: unknown) {
super(did, message, 'did-invalid', 400, cause)
}
}

258
packages/did/src/did.ts Normal file
View File

@ -0,0 +1,258 @@
import { z } from 'zod'
import { DidError, InvalidDidError } from './did-error.js'
const DID_PREFIX = 'did:'
const DID_PREFIX_LENGTH = DID_PREFIX.length
export { DID_PREFIX }
/**
* Type representation of a Did, with method.
*
* ```bnf
* did = "did:" method-name ":" method-specific-id
* method-name = 1*method-char
* method-char = %x61-7A / DIGIT
* method-specific-id = *( *idchar ":" ) 1*idchar
* idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded
* pct-encoded = "%" HEXDIG HEXDIG
* ```
*
* @example
* ```ts
* type DidWeb = Did<'web'> // `did:web:${string}`
* type DidCustom = Did<'web' | 'plc'> // `did:${'web' | 'plc'}:${string}`
* type DidNever = Did<' invalid 🥴 '> // never
* type DidFoo = Did<'foo' | ' invalid 🥴 '> // `did:foo:${string}`
* ```
*
* @see {@link https://www.w3.org/TR/did-core/#did-syntax}
*/
export type Did<M extends string = string> = `did:${AsDidMethod<M>}:${string}`
/**
* DID Method
*/
export type AsDidMethod<M> = string extends M
? string // can't know...
: AsDidMethodInternal<M, ''>
type AlphanumericChar = DigitChar | LowerAlphaChar
type DigitChar = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
type LowerAlphaChar =
| 'a'
| 'b'
| 'c'
| 'd'
| 'e'
| 'f'
| 'g'
| 'h'
| 'i'
| 'j'
| 'k'
| 'l'
| 'm'
| 'n'
| 'o'
| 'p'
| 'q'
| 'r'
| 's'
| 't'
| 'u'
| 'v'
| 'w'
| 'x'
| 'y'
| 'z'
type AsDidMethodInternal<
S,
Acc extends string,
> = S extends `${infer H}${infer T}`
? H extends AlphanumericChar
? AsDidMethodInternal<T, `${Acc}${H}`>
: never
: Acc extends ''
? never
: Acc
/**
* DID Method-name check function.
*
* Check if the input is a valid DID method name, at the position between
* `start` (inclusive) and `end` (exclusive).
*/
export function checkDidMethod(
input: string,
start = 0,
end = input.length,
): void {
if (
!Number.isFinite(end) ||
!Number.isFinite(start) ||
end < start ||
end > input.length
) {
throw new TypeError('Invalid start or end position')
}
if (end === start) {
throw new InvalidDidError(input, `Empty method name`)
}
let c: number
for (let i = start; i < end; i++) {
c = input.charCodeAt(i)
if (
(c < 0x61 || c > 0x7a) && // a-z
(c < 0x30 || c > 0x39) // 0-9
) {
throw new InvalidDidError(
input,
`Invalid character at position ${i} in DID method name`,
)
}
}
}
/**
* This method assumes the input is a valid Did
*/
export function extractDidMethod<D extends Did>(did: D) {
const msidSep = did.indexOf(':', DID_PREFIX_LENGTH)
const method = did.slice(DID_PREFIX_LENGTH, msidSep)
return method as D extends Did<infer M> ? M : string
}
/**
* DID Method-specific identifier check function.
*
* Check if the input is a valid DID method-specific identifier, at the position
* between `start` (inclusive) and `end` (exclusive).
*/
export function checkDidMsid(
input: string,
start = 0,
end = input.length,
): void {
if (
!Number.isFinite(end) ||
!Number.isFinite(start) ||
end < start ||
end > input.length
) {
throw new TypeError('Invalid start or end position')
}
if (end === start) {
throw new InvalidDidError(input, `DID method-specific id must not be empty`)
}
let c: number
for (let i = start; i < end; i++) {
c = input.charCodeAt(i)
// Check for frequent chars first
if (
(c < 0x61 || c > 0x7a) && // a-z
(c < 0x41 || c > 0x5a) && // A-Z
(c < 0x30 || c > 0x39) && // 0-9
c !== 0x2e && // .
c !== 0x2d && // -
c !== 0x5f // _
) {
// Less frequent chars are checked here
// ":"
if (c === 0x3a) {
if (i === end - 1) {
throw new InvalidDidError(input, `DID cannot end with ":"`)
}
continue
}
// pct-encoded
if (c === 0x25) {
c = input.charCodeAt(++i)
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
throw new InvalidDidError(
input,
`Invalid pct-encoded character at position ${i}`,
)
}
c = input.charCodeAt(++i)
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
throw new InvalidDidError(
input,
`Invalid pct-encoded character at position ${i}`,
)
}
// There must always be 2 HEXDIG after a "%"
if (i >= end) {
throw new InvalidDidError(
input,
`Incomplete pct-encoded character at position ${i - 2}`,
)
}
continue
}
throw new InvalidDidError(
input,
`Disallowed character in DID at position ${i}`,
)
}
}
}
export function checkDid(input: unknown): asserts input is Did {
if (typeof input !== 'string') {
throw new InvalidDidError(typeof input, `DID must be a string`)
}
const { length } = input
if (length > 2048) {
throw new InvalidDidError(input, `DID is too long (2048 chars max)`)
}
if (!input.startsWith(DID_PREFIX)) {
throw new InvalidDidError(input, `DID requires "${DID_PREFIX}" prefix`)
}
const idSep = input.indexOf(':', DID_PREFIX_LENGTH)
if (idSep === -1) {
throw new InvalidDidError(input, `Missing colon after method name`)
}
checkDidMethod(input, DID_PREFIX_LENGTH, idSep)
checkDidMsid(input, idSep + 1, length)
}
export function isDid(input: unknown): input is Did {
try {
checkDid(input)
return true
} catch (err) {
if (err instanceof DidError) {
return false
}
throw err
}
}
export const didSchema = z
.string()
.superRefine((value: string, ctx: z.RefinementCtx): value is Did => {
try {
checkDid(value)
return true
} catch (err) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: err instanceof Error ? err.message : 'Unexpected error',
})
return false
}
})

View File

@ -0,0 +1,4 @@
export * from './did-document.js'
export * from './did-error.js'
export * from './did.js'
export * from './methods.js'

View File

@ -0,0 +1,2 @@
export * from './methods/plc.js'
export * from './methods/web.js'

View File

@ -0,0 +1,40 @@
import { InvalidDidError } from '../did-error.js'
import { Did } from '../did.js'
const DID_PLC_PREFIX = `did:plc:`
const DID_PLC_PREFIX_LENGTH = DID_PLC_PREFIX.length
const DID_PLC_LENGTH = 32
export { DID_PLC_PREFIX }
export function isDidPlc(input: unknown): input is Did<'plc'> {
if (typeof input !== 'string') return false
try {
checkDidPlc(input)
return true
} catch {
return false
}
}
export function checkDidPlc(input: string): asserts input is Did<'plc'> {
if (input.length !== DID_PLC_LENGTH) {
throw new InvalidDidError(
input,
`did:plc must be ${DID_PLC_LENGTH} characters long`,
)
}
if (!input.startsWith(DID_PLC_PREFIX)) {
throw new InvalidDidError(input, `Invalid did:plc prefix`)
}
let c: number
for (let i = DID_PLC_PREFIX_LENGTH; i < DID_PLC_LENGTH; i++) {
c = input.charCodeAt(i)
// Base32 encoding ([a-z2-7])
if ((c < 0x61 || c > 0x7a) && (c < 0x32 || c > 0x37)) {
throw new InvalidDidError(input, `Invalid character at position ${i}`)
}
}
}

View File

@ -0,0 +1,78 @@
import { InvalidDidError } from '../did-error.js'
import { Did, checkDidMsid } from '../did.js'
export const DID_WEB_PREFIX = `did:web:`
/**
* This function checks if the input is a valid Web DID, as per DID spec.
* ATPROTO adds additional constraints to allowed DID values for the `did:web`
* method. Use {@link isAtprotoDidWeb} if that's what you need.
*/
export function isDidWeb(input: unknown): input is Did<'web'> {
if (typeof input !== 'string') return false
try {
didWebToUrl(input)
return true
} catch {
return false
}
}
/**
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
*/
export function isAtprotoDidWeb(input: unknown): input is Did<'web'> {
// Optimization: make cheap checks first
if (typeof input !== 'string') {
return false
}
// Path are not allowed
if (input.includes(':', DID_WEB_PREFIX.length)) {
return false
}
// Port numbers are not allowed, except for localhost
if (
input.includes('%3A', DID_WEB_PREFIX.length) &&
!input.startsWith('did:web:localhost%3A')
) {
return false
}
return isDidWeb(input)
}
export function checkDidWeb(input: string): asserts input is Did<'web'> {
didWebToUrl(input)
}
export function didWebToUrl(did: string): URL {
if (!did.startsWith(DID_WEB_PREFIX)) {
throw new InvalidDidError(did, `did:web must start with ${DID_WEB_PREFIX}`)
}
if (did.charAt(DID_WEB_PREFIX.length) === ':') {
throw new InvalidDidError(did, 'did:web MSID must not start with a colon')
}
// Make sure every char is valid (per DID spec)
checkDidMsid(did, DID_WEB_PREFIX.length)
try {
const msid = did.slice(DID_WEB_PREFIX.length)
const parts = msid.split(':').map(decodeURIComponent)
return new URL(`https://${parts.join('/')}`)
} catch (cause) {
throw new InvalidDidError(did, 'Invalid Web DID', cause)
}
}
export function urlToDidWeb(url: URL): Did<'web'> {
const path =
url.pathname === '/'
? ''
: url.pathname.slice(1).split('/').map(encodeURIComponent).join(':')
return `did:web:${encodeURIComponent(url.host)}${path ? `:${path}` : ''}`
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig/isomorphic.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,40 @@
{
"name": "@atproto-labs/did-resolver",
"version": "0.0.1",
"license": "MIT",
"description": "DID resolution and verification library",
"keywords": [
"atproto",
"did",
"resolver"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/did-resolver"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/fetch": "workspace:*",
"@atproto-labs/pipe": "workspace:*",
"@atproto-labs/simple-store": "workspace:*",
"@atproto-labs/simple-store-memory": "workspace:*",
"@atproto/did": "workspace:*",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,25 @@
import { Did, DidDocument } from '@atproto/did'
import {
SimpleStoreMemory,
SimpleStoreMemoryOptions,
} from '@atproto-labs/simple-store-memory'
import { DidCache } from './did-cache.js'
const DEFAULT_TTL = 3600 * 1000 // 1 hour
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024 // ~50MB
export type DidCacheMemoryOptions = SimpleStoreMemoryOptions<Did, DidDocument>
export class DidCacheMemory
extends SimpleStoreMemory<Did, DidDocument>
implements DidCache
{
constructor(options?: DidCacheMemoryOptions) {
super(
options?.max == null
? { ttl: DEFAULT_TTL, maxSize: DEFAULT_MAX_SIZE, ...options }
: { ttl: DEFAULT_TTL, ...options },
)
}
}

View File

@ -0,0 +1,31 @@
import { CachedGetter, SimpleStore } from '@atproto-labs/simple-store'
import { Did, DidDocument } from '@atproto/did'
import { DidCacheMemory } from './did-cache-memory.js'
import { DidMethod, ResolveOptions } from './did-method.js'
import { DidResolver, ResolvedDocument } from './did-resolver.js'
export type { DidMethod, ResolveOptions, ResolvedDocument }
export type DidCache = SimpleStore<Did, DidDocument>
export type DidResolverCachedOptions = { cache?: DidCache }
export class DidResolverCached<M extends string = string>
implements DidResolver<M>
{
protected readonly getter: CachedGetter<Did, DidDocument>
constructor(
resolver: DidResolver<M>,
cache: DidCache = new DidCacheMemory(),
) {
this.getter = new CachedGetter<Did, DidDocument>(
(did, options) => resolver.resolve(did, options),
cache,
)
}
public async resolve<D extends Did>(did: D, options?: ResolveOptions) {
return this.getter.get(did, options) as Promise<ResolvedDocument<D, M>>
}
}

View File

@ -0,0 +1,17 @@
import { Did, DidDocument } from '@atproto/did'
export type ResolveOptions = {
signal?: AbortSignal
noCache?: boolean
}
export interface DidMethod<Method extends string> {
resolve: (
did: Did<Method>,
options?: ResolveOptions,
) => DidDocument | PromiseLike<DidDocument>
}
export type DidMethods<M extends string> = {
[K in M]: DidMethod<K>
}

View File

@ -0,0 +1,66 @@
import { FetchRequestError } from '@atproto-labs/fetch'
import { Did, DidError, extractDidMethod } from '@atproto/did'
import { ZodError } from 'zod'
import { DidMethod, DidMethods, ResolveOptions } from './did-method.js'
import { DidResolver, ResolvedDocument } from './did-resolver.js'
export type { DidMethod, ResolveOptions, ResolvedDocument }
export class DidResolverBase<M extends string = string>
implements DidResolver<M>
{
protected readonly methods: Map<string, DidMethod<M>>
constructor(methods: DidMethods<M>) {
this.methods = new Map(Object.entries(methods))
}
async resolve<D extends Did>(
did: D,
options?: ResolveOptions,
): Promise<ResolvedDocument<D, M>> {
options?.signal?.throwIfAborted()
const method = extractDidMethod(did)
const resolver = this.methods.get(method)
if (!resolver) {
throw new DidError(
did,
`Unsupported DID method`,
'did-method-invalid',
400,
)
}
try {
const document = await resolver.resolve(did as Did<M>, options)
if (document.id !== did) {
throw new DidError(
did,
`DID document id (${document.id}) does not match DID`,
'did-document-id-mismatch',
400,
)
}
return document as ResolvedDocument<D, M>
} catch (err) {
if (err instanceof FetchRequestError) {
throw new DidError(did, err.message, 'did-fetch-error', 400, err)
}
if (err instanceof ZodError) {
throw new DidError(
did,
err.message,
'did-document-format-error',
503,
err,
)
}
throw DidError.from(err, did)
}
}
}

View File

@ -0,0 +1,20 @@
import { DidResolverBase } from './did-resolver-base.js'
import { DidPlcMethod, DidPlcMethodOptions } from './methods/plc.js'
import { DidWebMethod, DidWebMethodOptions } from './methods/web.js'
import { Simplify } from './util.js'
export type DidResolverCommonOptions = Simplify<
DidPlcMethodOptions & DidWebMethodOptions
>
export class DidResolverCommon
extends DidResolverBase<'plc' | 'web'>
implements DidResolverBase<'plc' | 'web'>
{
constructor(options?: DidResolverCommonOptions) {
super({
plc: new DidPlcMethod(options),
web: new DidWebMethod(options),
})
}
}

View File

@ -0,0 +1,15 @@
import { Did, DidDocument } from '@atproto/did'
import { ResolveOptions } from './did-method.js'
export type ResolvedDocument<D extends Did, M extends string = string> =
D extends Did<infer N>
? DidDocument<N extends string ? M : N extends M ? N : never>
: never
export interface DidResolver<M extends string = string> {
resolve<D extends Did>(
did: D,
options?: ResolveOptions,
): Promise<ResolvedDocument<D, M>>
}

View File

@ -0,0 +1,9 @@
export * from '@atproto/did'
export * from './did-cache-memory.js'
export * from './did-cache.js'
export * from './did-method.js'
export * from './did-resolver-common.js'
export * from './did-resolver.js'
export * from './methods.js'
export * from './util.js'

View File

@ -0,0 +1,2 @@
export * from './methods/plc.js'
export * from './methods/web.js'

View File

@ -0,0 +1,56 @@
import {
Fetch,
bindFetch,
fetchJsonProcessor,
fetchJsonZodProcessor,
fetchOkProcessor,
} from '@atproto-labs/fetch'
import { pipe } from '@atproto-labs/pipe'
import { Did, checkDidPlc, didDocumentValidator } from '@atproto/did'
import { DidMethod, ResolveOptions } from '../did-method.js'
const fetchSuccessHandler = pipe(
fetchOkProcessor(),
fetchJsonProcessor(/^application\/(did\+ld\+)?json$/),
fetchJsonZodProcessor(didDocumentValidator),
)
export type DidPlcMethodOptions = {
/**
* @default globalThis.fetch
*/
fetch?: Fetch
/**
* @default 'https://plc.directory/'
*/
plcDirectoryUrl?: string | URL
}
export class DidPlcMethod implements DidMethod<'plc'> {
protected readonly fetch: Fetch<unknown>
public readonly plcDirectoryUrl: URL
constructor(options?: DidPlcMethodOptions) {
this.plcDirectoryUrl = new URL(
options?.plcDirectoryUrl || 'https://plc.directory/',
)
this.fetch = bindFetch(options?.fetch)
}
async resolve(did: Did<'plc'>, options?: ResolveOptions) {
// Although the did should start with `did:plc:` (thanks to typings), we
// should still check if the msid is valid.
checkDidPlc(did)
const url = new URL(`/${did}`, this.plcDirectoryUrl)
return this.fetch(url, {
redirect: 'error',
headers: { accept: 'application/did+ld+json,application/json' },
signal: options?.signal,
}).then(fetchSuccessHandler)
}
}

View File

@ -0,0 +1,58 @@
import {
bindFetch,
Fetch,
fetchJsonProcessor,
fetchJsonZodProcessor,
fetchOkProcessor,
} from '@atproto-labs/fetch'
import { pipe } from '@atproto-labs/pipe'
import { Did, didDocumentValidator, didWebToUrl } from '@atproto/did'
import { DidMethod, ResolveOptions } from '../did-method.js'
const fetchSuccessHandler = pipe(
fetchOkProcessor(),
fetchJsonProcessor(/^application\/(did\+ld\+)?json$/),
fetchJsonZodProcessor(didDocumentValidator),
)
export type DidWebMethodOptions = {
fetch?: Fetch
}
export class DidWebMethod implements DidMethod<'web'> {
protected readonly fetch: Fetch<unknown>
constructor({ fetch = globalThis.fetch }: DidWebMethodOptions = {}) {
this.fetch = bindFetch(fetch)
}
async resolve(did: Did<'web'>, options?: ResolveOptions) {
const didDocumentUrl = buildDidWebDocumentUrl(did)
return this.fetch(didDocumentUrl, {
redirect: 'error',
headers: { accept: 'application/did+ld+json,application/json' },
signal: options?.signal,
}).then(fetchSuccessHandler)
}
}
/**
* @see {@link https://datatracker.ietf.org/doc/html/rfc8615}
* @see {@link https://w3c-ccg.github.io/did-method-web/#create-register}
*/
export function buildDidWebDocumentUrl(did: Did<'web'>) {
const url = didWebToUrl(did) // Will throw if the DID is invalid
// Note: DID cannot end with an `:`, so they cannot end with a `/`. This is
// true unless when there is no path at all, in which case the URL constructor
// will set the pathname to `/`.
// https://w3c-ccg.github.io/did-method-web/#read-resolve
if (url.pathname === '/') {
return new URL(`/.well-known/did.json`, url)
} else {
return new URL(`${url.pathname}/did.json`, url)
}
}

View File

@ -0,0 +1 @@
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/isomorphic.json"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*.ts"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,40 @@
{
"name": "@atproto-labs/fetch-node",
"version": "0.0.1",
"license": "MIT",
"description": "SSRF protection for fetch() in Node.js",
"keywords": [
"atproto",
"fetch",
"node"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/fetch-node"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/fetch": "workspace:*",
"@atproto-labs/pipe": "workspace:*",
"ipaddr.js": "^2.1.0",
"psl": "^1.9.0",
"undici": "^6.14.1"
},
"devDependencies": {
"@types/psl": "1.1.3",
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}
}

View File

@ -0,0 +1,4 @@
export * from '@atproto-labs/fetch'
export * from './safe.js'
export * from './ssrf.js'

View File

@ -0,0 +1,78 @@
import {
DEFAULT_FORBIDDEN_DOMAIN_NAMES,
Fetch,
fetchMaxSizeProcessor,
forbiddenDomainNameRequestTransform,
protocolCheckRequestTransform,
requireHostHeaderTranform,
timedFetch,
toRequestTransformer,
} from '@atproto-labs/fetch'
import { pipe } from '@atproto-labs/pipe'
import { ssrfFetchWrap } from './ssrf.js'
export type SafeFetchWrapOptions = NonNullable<
Parameters<typeof safeFetchWrap>[0]
>
/**
* Wrap a fetch function with safety checks so that it can be safely used
* with user provided input (URL).
*/
export function safeFetchWrap({
fetch = globalThis.fetch as Fetch,
responseMaxSize = 512 * 1024, // 512kB
allowHttp = false,
allowData = false,
ssrfProtection = true,
timeout = 10e3,
forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
} = {}): Fetch<unknown> {
return toRequestTransformer(
pipe(
/**
* Prevent using http:, file: or data: protocols.
*/
protocolCheckRequestTransform(
['https:']
.concat(allowHttp ? ['http:'] : [])
.concat(allowData ? ['data:'] : []),
),
/**
* Only requests that will be issued with a "Host" header are allowed.
*/
requireHostHeaderTranform(),
/**
* Disallow fetching from domains we know are not atproto/OIDC client
* implementation. Note that other domains can be blocked by providing a
* custom fetch function combined with another
* forbiddenDomainNameRequestTransform.
*/
forbiddenDomainNameRequestTransform(forbiddenDomainNames),
/**
* Since we will be fetching from the network based on user provided
* input, let's mitigate resource exhaustion attacks by setting a timeout.
*/
timedFetch(
timeout,
/**
* Since we will be fetching from the network based on user provided
* input, we need to make sure that the request is not vulnerable to SSRF
* attacks.
*/
ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch,
),
/**
* Since we will be fetching user owned data, we need to make sure that an
* attacker cannot force us to download a large amounts of data.
*/
fetchMaxSizeProcessor(responseMaxSize),
),
)
}

View File

@ -0,0 +1,214 @@
import dns, { LookupAddress } from 'node:dns'
import { LookupFunction } from 'node:net'
import {
Fetch,
FetchContext,
FetchRequestError,
toRequestTransformer,
} from '@atproto-labs/fetch'
import ipaddr from 'ipaddr.js'
import { isValid as isValidDomain } from 'psl'
import { Agent } from 'undici'
const { IPv4, IPv6 } = ipaddr
const [NODE_VERSION] = process.versions.node.split('.').map(Number)
export type SsrfFetchWrapOptions<C = FetchContext> = {
allowCustomPort?: boolean
allowUnknownTld?: boolean
fetch?: Fetch<C>
}
/**
* @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
*/
export function ssrfFetchWrap<C = FetchContext>({
allowCustomPort = false,
allowUnknownTld = false,
fetch = globalThis.fetch,
}: SsrfFetchWrapOptions<C>): Fetch<C> {
const ssrfAgent = new Agent({ connect: { lookup } })
return toRequestTransformer(async function (
this: C,
request,
): Promise<Response> {
const url = new URL(request.url)
if (url.protocol === 'data:') {
// No SSRF issue
return fetch.call(this, request)
}
if (url.protocol === 'http:' || url.protocol === 'https:') {
// @ts-expect-error non-standard option
if (request.dispatcher) {
throw new FetchRequestError(
request,
500,
'SSRF protection cannot be used with a custom request dispatcher',
)
}
// Check port (OWASP)
if (url.port && !allowCustomPort) {
throw new FetchRequestError(
request,
400,
'Request port must be omitted or standard when SSRF is enabled',
)
}
// Disable HTTP redirections (OWASP)
if (request.redirect === 'follow') {
throw new FetchRequestError(
request,
500,
'Request redirect must be "error" or "manual" when SSRF is enabled',
)
}
// If the hostname is an IP address, it must be a unicast address.
const ip = parseIpHostname(url.hostname)
if (ip) {
if (ip.range() !== 'unicast') {
throw new FetchRequestError(
request,
400,
'Hostname resolved to non-unicast address',
)
}
// No additional check required
return fetch.call(this, request)
}
if (allowUnknownTld !== true && !isValidDomain(url.hostname)) {
throw new FetchRequestError(
request,
400,
'Hostname is not a public domain',
)
}
// Else hostname is a domain name, use DNS lookup to check if it resolves
// to a unicast address
if (NODE_VERSION < 21) {
// Note: due to the issue nodejs/undici#2828 (fixed in undici >=6.7.0,
// Node >=21), the "dispatcher" property of the request object will not
// be used by fetch(). As a workaround, we pass the dispatcher as second
// argument to fetch() here, and make sure it is used (which might not be
// the case if a custom fetch() function is used).
if (fetch === globalThis.fetch) {
// If the global fetch function is used, we can pass the dispatcher
// singleton directly to the fetch function as we know it will be
// used.
// @ts-expect-error non-standard option
return fetch.call(this, request, { dispatcher: ssrfAgent })
}
let didLookup = false
const dispatcher = new Agent({
connect: {
lookup(...args) {
didLookup = true
lookup(...args)
},
},
})
try {
// @ts-expect-error non-standard option
return await fetch.call(this, request, { dispatcher })
} finally {
// Free resources (we cannot await here since the response was not
// consumed yet).
void dispatcher.close().catch((err) => {
// No biggie, but let's still log it
console.warn('Failed to close dispatcher', err)
})
if (!didLookup) {
// If you encounter this error, either upgrade to Node.js >=21 or
// make sure that the requestInit object is passed as second
// argument to the global fetch function.
// eslint-disable-next-line no-unsafe-finally
throw new FetchRequestError(
request,
500,
'Unable to enforce SSRF protection',
)
}
}
}
// @ts-expect-error non-standard option
return fetch(new Request(request, { dispatcher: ssrfAgent }))
}
// blob: about: file: all should be rejected
throw new FetchRequestError(
request,
400,
`Forbidden protocol "${url.protocol}"`,
)
})
}
function parseIpHostname(
hostname: string,
): ipaddr.IPv4 | ipaddr.IPv6 | undefined {
if (IPv4.isIPv4(hostname)) {
return IPv4.parse(hostname)
}
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return IPv6.parse(hostname.slice(1, -1))
}
return undefined
}
function lookup(
hostname: string,
options: dns.LookupOptions,
callback: Parameters<LookupFunction>[2],
) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, address, family)
} else {
const ips = Array.isArray(address)
? address.map(parseLookupAddress)
: [parseLookupAddress({ address, family })]
if (ips.some((ip) => ip.range() !== 'unicast')) {
callback(
new Error('Hostname resolved to non-unicast address'),
address,
family,
)
} else {
callback(null, address, family)
}
}
})
}
function parseLookupAddress({
address,
family,
}: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {
const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
return ip.toIPv4Address()
} else {
return ip
}
}

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/node.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,37 @@
{
"name": "@atproto-labs/fetch",
"version": "0.0.1",
"license": "MIT",
"description": "Isomorphic wrapper utilities for fetch API",
"keywords": [
"atproto",
"fetch"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/fetch"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/pipe": "workspace:*"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"optionalDependencies": {
"zod": "^3.23.8"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}
}

View File

@ -0,0 +1,59 @@
export class FetchError extends Error {
public readonly statusCode: number
constructor(statusCode?: number, message?: string, options?: ErrorOptions) {
if (statusCode == null || !message) {
const info = extractInfo(extractRootCause(options?.cause))
statusCode = statusCode ?? info[0]
message = message || info[1]
}
super(message, options)
this.statusCode = statusCode
}
}
function extractRootCause(err: unknown): unknown {
// Unwrap the Network error from undici (i.e. Node's internal fetch() implementation)
// https://github.com/nodejs/undici/blob/3274c975947ce11a08508743df026f73598bfead/lib/web/fetch/index.js#L223-L228
if (
err instanceof TypeError &&
err.message === 'fetch failed' &&
err.cause !== undefined
) {
return err.cause
}
return err
}
function extractInfo(err: unknown): [statusCode: number, message: string] {
if (typeof err === 'string' && err.length > 0) {
return [500, err]
}
if (!(err instanceof Error)) {
return [500, 'Failed to fetch']
}
const code = err['code']
if (typeof code === 'string') {
switch (true) {
case code === 'ENOTFOUND':
return [400, 'Invalid hostname']
case code === 'ECONNREFUSED':
return [502, 'Connection refused']
case code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
return [502, 'Self-signed certificate']
case code.startsWith('ERR_TLS'):
return [502, 'TLS error']
case code.startsWith('ECONN'):
return [502, 'Connection error']
default:
return [500, `${code} error`]
}
}
return [500, err.message]
}

View File

@ -0,0 +1,118 @@
import { FetchError } from './fetch-error.js'
import { asRequest } from './fetch.js'
import { isIp } from './util.js'
export class FetchRequestError extends FetchError {
constructor(
public readonly request: Request,
statusCode?: number,
message?: string,
options?: ErrorOptions,
) {
super(statusCode, message, options)
}
static from(request: Request, cause: unknown): FetchRequestError {
if (cause instanceof FetchRequestError) return cause
return new FetchRequestError(request, undefined, undefined, { cause })
}
}
const extractUrl = (input: Request | string | URL) =>
typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url)
export function protocolCheckRequestTransform(protocols: Iterable<string>) {
const allowedProtocols = new Set<string>(protocols)
return (input: Request | string | URL, init?: RequestInit) => {
const { protocol } = extractUrl(input)
const request = asRequest(input, init)
if (!allowedProtocols.has(protocol)) {
throw new FetchRequestError(
request,
400,
`"${protocol}" protocol is not allowed`,
)
}
return request
}
}
export function requireHostHeaderTranform() {
return (input: Request | string | URL, init?: RequestInit) => {
// Note that fetch() will automatically add the Host header from the URL and
// discard any Host header manually set in the request.
const { protocol, hostname } = extractUrl(input)
const request = asRequest(input, init)
// "Host" header only makes sense in the context of an HTTP request
if (protocol !== 'http:' && protocol !== 'https:') {
throw new FetchRequestError(
request,
400,
`"${protocol}" requests are not allowed`,
)
}
if (!hostname || isIp(hostname)) {
throw new FetchRequestError(request, 400, 'Invalid hostname')
}
return request
}
}
export const DEFAULT_FORBIDDEN_DOMAIN_NAMES = [
'example.com',
'*.example.com',
'example.org',
'*.example.org',
'example.net',
'*.example.net',
'googleusercontent.com',
'*.googleusercontent.com',
]
export function forbiddenDomainNameRequestTransform(
denyList: Iterable<string> = DEFAULT_FORBIDDEN_DOMAIN_NAMES,
) {
const denySet = new Set<string>(denyList)
// Optimization: if no forbidden domain names are provided, we can skip the
// check entirely.
if (denySet.size === 0) {
return async (request) => request
}
return async (input: Request | string | URL, init?: RequestInit) => {
const { hostname } = extractUrl(input)
const request = asRequest(input, init)
// Full domain name check
if (denySet.has(hostname)) {
throw new FetchRequestError(request, 403, 'Forbidden hostname')
}
// Sub domain name check
let curDot = hostname.indexOf('.')
while (curDot !== -1) {
const subdomain = hostname.slice(curDot + 1)
if (denySet.has(`*.${subdomain}`)) {
throw new FetchRequestError(request, 403, 'Forbidden hostname')
}
curDot = hostname.indexOf('.', curDot + 1)
}
return request
}
}

View File

@ -0,0 +1,278 @@
import { Transformer, pipe } from '@atproto-labs/pipe'
// optional dependency for typing purposes
import type { ZodTypeAny, ParseParams, TypeOf } from 'zod'
import { FetchError } from './fetch-error.js'
import { TransformedResponse } from './transformed-response.js'
import {
Json,
MaxBytesTransformStream,
cancelBody,
ifObject,
ifString,
logCancellationError,
} from './util.js'
export type ResponseTranformer = Transformer<Response>
export type ResponseMessageGetter = Transformer<Response, string | undefined>
export class FetchResponseError extends FetchError {
constructor(
public readonly response: Response,
statusCode: number = response.status,
message: string = response.statusText,
options?: ErrorOptions,
) {
super(statusCode, message, options)
}
static async from(
response: Response,
customMessage: string | ResponseMessageGetter = extractResponseMessage,
statusCode = response.status,
options?: ErrorOptions,
) {
const message =
typeof customMessage === 'string'
? customMessage
: typeof customMessage === 'function'
? await customMessage(response)
: undefined
return new FetchResponseError(response, statusCode, message, options)
}
}
const extractResponseMessage: ResponseMessageGetter = async (response) => {
const mimeType = extractMime(response)
if (!mimeType) return undefined
try {
if (mimeType === 'text/plain') {
return await response.text()
} else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) {
const json: unknown = await response.json()
if (typeof json === 'string') return json
const errorDescription = ifString(ifObject(json)?.['error_description'])
if (errorDescription) return errorDescription
const error = ifString(ifObject(json)?.['error'])
if (error) return error
const message = ifString(ifObject(json)?.['message'])
if (message) return message
}
} catch {
// noop
}
return undefined
}
export async function peekJson(
response: Response,
maxSize = Infinity,
): Promise<undefined | Json> {
const type = extractMime(response)
if (type !== 'application/json') return undefined
checkLength(response, maxSize)
// 1) Clone the request so we can consume the body
const clonedResponse = response.clone()
// 2) Make sure the request's body is not too large
const limitedResponse =
response.body && maxSize < Infinity
? new TransformedResponse(
clonedResponse,
new MaxBytesTransformStream(maxSize),
)
: // Note: some runtimes (e.g. react-native) don't expose a body property
clonedResponse
// 3) Parse the JSON
return limitedResponse.json()
}
export function checkLength(response: Response, maxBytes: number) {
// Note: negation accounts for invalid value types (NaN, non numbers)
if (!(maxBytes >= 0)) {
throw new TypeError('maxBytes must be a non-negative number')
}
const length = extractLength(response)
if (length != null && length > maxBytes) {
throw new FetchResponseError(response, 502, 'Response too large')
}
return length
}
export function extractLength(response: Response) {
const contentLength = response.headers.get('Content-Length')
if (contentLength == null) return undefined
if (!/^\d+$/.test(contentLength)) {
throw new FetchResponseError(response, 502, 'Invalid Content-Length')
}
const length = Number(contentLength)
if (!Number.isSafeInteger(length)) {
throw new FetchResponseError(response, 502, 'Content-Length too large')
}
return length
}
export function extractMime(response: Response) {
const contentType = response.headers.get('Content-Type')
if (contentType == null) return undefined
return contentType.split(';', 1)[0]!.trim()
}
/**
* If the transformer results in an error, ensure that the response body is
* consumed as, in some environments (Node 👀), the response will not
* automatically be GC'd.
*
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
* @param [onCancellationError] - Callback to handle any async body cancelling
* error. Defaults to logging the error. Do not use `null` if the request is
* cloned.
*/
export function cancelBodyOnError<T>(
transformer: Transformer<Response, T>,
onCancellationError: null | ((err: unknown) => void) = logCancellationError,
): (response: Response) => Promise<T> {
return async (response) => {
try {
return await transformer(response)
} catch (err) {
await cancelBody(response, onCancellationError ?? undefined)
throw err
}
}
}
export function fetchOkProcessor(
customMessage?: string | ResponseMessageGetter,
): ResponseTranformer {
return cancelBodyOnError((response) => {
return fetchOkTransformer(response, customMessage)
})
}
export async function fetchOkTransformer(
response: Response,
customMessage?: string | ResponseMessageGetter,
) {
if (response.ok) return response
throw await FetchResponseError.from(response, customMessage)
}
export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer {
if (maxBytes === Infinity) return (response) => response
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
throw new TypeError('maxBytes must be a 0, Infinity or a positive number')
}
return cancelBodyOnError((response) => {
return fetchResponseMaxSizeChecker(response, maxBytes)
})
}
export function fetchResponseMaxSizeChecker(
response: Response,
maxBytes: number,
): Response {
if (maxBytes === Infinity) return response
checkLength(response, maxBytes)
// Some engines (react-native 👀) don't expose a body property. In that case,
// we will only rely on the Content-Length header.
if (!response.body) return response
const transform = new MaxBytesTransformStream(maxBytes)
return new TransformedResponse(response, transform)
}
export type MimeTypeCheckFn = (mimeType: string) => boolean
export type MimeTypeCheck = string | RegExp | MimeTypeCheckFn
export function fetchTypeProcessor(
expectedMime: MimeTypeCheck,
contentTypeRequired = true,
): ResponseTranformer {
const isExpected: MimeTypeCheckFn =
typeof expectedMime === 'string'
? (mimeType) => mimeType === expectedMime
: expectedMime instanceof RegExp
? (mimeType) => expectedMime.test(mimeType)
: expectedMime
return cancelBodyOnError((response) => {
return fetchResponseTypeChecker(response, isExpected, contentTypeRequired)
})
}
export async function fetchResponseTypeChecker(
response: Response,
isExpectedMime: MimeTypeCheckFn,
contentTypeRequired = true,
): Promise<Response> {
const mimeType = extractMime(response)
if (mimeType) {
if (!isExpectedMime(mimeType)) {
throw await FetchResponseError.from(
response,
`Unexpected response Content-Type (${mimeType})`,
502,
)
}
} else if (contentTypeRequired) {
throw await FetchResponseError.from(
response,
'Missing response Content-Type header',
502,
)
}
return response
}
export type ParsedJsonResponse<T = Json> = {
response: Response
json: T
}
export async function fetchResponseJsonTranformer<T = Json>(
response: Response,
): Promise<ParsedJsonResponse<T>> {
try {
const json = (await response.json()) as T
return { response, json }
} catch (cause) {
throw new FetchResponseError(
response,
502,
'Unable to parse response as JSON',
{ cause },
)
}
}
export function fetchJsonProcessor<T = Json>(
expectedMime: MimeTypeCheck = /^application\/(?:[^+]+\+)?json$/,
contentTypeRequired = true,
): Transformer<Response, ParsedJsonResponse<T>> {
return pipe(
fetchTypeProcessor(expectedMime, contentTypeRequired),
cancelBodyOnError(fetchResponseJsonTranformer<T>),
)
}
export function fetchJsonZodProcessor<S extends ZodTypeAny>(
schema: S,
params?: Partial<ParseParams>,
): Transformer<ParsedJsonResponse, TypeOf<S>> {
return async (jsonResponse: ParsedJsonResponse): Promise<TypeOf<S>> =>
schema.parseAsync(jsonResponse.json, params)
}

View File

@ -0,0 +1,122 @@
import { FetchRequestError } from './fetch-request.js'
import { Fetch, FetchContext, toRequestTransformer } from './fetch.js'
import { TransformedResponse } from './transformed-response.js'
import { padLines, stringifyMessage } from './util.js'
export function loggedFetch<C = FetchContext>(
fetch: Fetch<C> = globalThis.fetch,
) {
return toRequestTransformer(async function (
this: C,
request,
): Promise<Response> {
const requestMessage = await stringifyMessage(request)
console.info(
`> ${request.method} ${request.url}\n${padLines(requestMessage, ' ')}`,
)
try {
const response = await fetch.call(this, request)
const responseMessage = await stringifyMessage(response.clone())
console.info(
`< HTTP/1.1 ${response.status} ${response.statusText}\n${padLines(responseMessage, ' ')}`,
)
return response
} catch (error) {
console.error(`< Error:`, error)
throw error
}
})
}
export const timedFetch = <C = FetchContext>(
timeout = 60e3,
fetch: Fetch<C> = globalThis.fetch,
): Fetch<C> => {
if (timeout === Infinity) return fetch
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new TypeError('Timeout must be positive')
}
return toRequestTransformer(async function (
this: C,
request,
): Promise<Response> {
const controller = new AbortController()
const signal = controller.signal
const abort = () => {
controller.abort()
}
const cleanup = () => {
clearTimeout(timer)
request.signal?.removeEventListener('abort', abort)
}
const timer = setTimeout(abort, timeout)
if (typeof timer === 'object') timer.unref?.() // only on node
request.signal?.addEventListener('abort', abort)
signal.addEventListener('abort', cleanup)
const response = await fetch.call(this, request, { signal })
if (!response.body) {
cleanup()
return response
} else {
// Cleanup the timer & event listeners when the body stream is closed
const transform = new TransformStream({ flush: cleanup })
return new TransformedResponse(response, transform)
}
})
}
/**
* Wraps a fetch function to bind it to a specific context, and wrap any thrown
* errors into a FetchRequestError.
*
* @example
*
* ```ts
* class MyClient {
* constructor(private fetch = globalThis.fetch) {}
*
* async get(url: string) {
* // This will generate an error, because the context used is not a
* // FetchContext (it's a MyClient instance).
* return this.fetch(url)
* }
* }
* ```
*
* @example
*
* ```ts
* class MyClient {
* private fetch: Fetch<unknown>
*
* constructor(fetch = globalThis.fetch) {
* this.fetch = bindFetch(fetch)
* }
*
* async get(url: string) {
* return this.fetch(url) // no more error
* }
* }
* ```
*/
export function bindFetch<C = FetchContext>(
fetch: Fetch<C> = globalThis.fetch,
context: C = globalThis as C,
) {
return toRequestTransformer(async (request) => {
try {
return await fetch.call(context, request)
} catch (err) {
throw FetchRequestError.from(request, err)
}
})
}

View File

@ -0,0 +1,39 @@
import { ThisParameterOverride } from './util.js'
export type FetchContext = void | null | typeof globalThis
export type FetchBound = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>
// NOT using "typeof globalThis.fetch" here because "globalThis.fetch" does not
// have a "this" parameter, while runtimes do ensure that "fetch" is called with
// the correct "this" parameter (either null, undefined, or window).
export type Fetch<C = FetchContext> = ThisParameterOverride<C, FetchBound>
export type SimpleFetchBound = (input: Request) => Promise<Response>
export type SimpleFetch<C = FetchContext> = ThisParameterOverride<
C,
SimpleFetchBound
>
export function toRequestTransformer<C, O>(
requestTransformer: (this: C, input: Request) => O,
): ThisParameterOverride<
C,
(input: string | URL | Request, init?: RequestInit) => O
> {
return function (this: C, input, init) {
return requestTransformer.call(this, asRequest(input, init))
}
}
export function asRequest(
input: string | URL | Request,
init?: RequestInit,
): Request {
if (!init && input instanceof Request) return input
return new Request(input, init)
}

View File

@ -0,0 +1,6 @@
export * from './fetch-error.js'
export * from './fetch-request.js'
export * from './fetch-response.js'
export * from './fetch-wrap.js'
export * from './fetch.js'
export * from './util.js'

View File

@ -0,0 +1,36 @@
export class TransformedResponse extends Response {
#response: Response
constructor(response: Response, transform: TransformStream) {
if (!response.body) {
throw new TypeError('Response body is not available')
}
if (response.bodyUsed) {
throw new TypeError('Response body is already used')
}
super(response.body.pipeThrough(transform), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
this.#response = response
}
/**
* Some props can't be set through ResponseInit, so we need to proxy them
*/
get url() {
return this.#response.url
}
get redirected() {
return this.#response.redirected
}
get type() {
return this.#response.type
}
get statusText() {
return this.#response.statusText
}
}

View File

@ -0,0 +1,169 @@
// @TODO: Move some of these to a shared package ?
export type JsonScalar = string | number | boolean | null
export type Json = JsonScalar | Json[] | { [key: string]: undefined | Json }
export type JsonObject = { [key: string]: Json }
export type JsonArray = Json[]
export type ThisParameterOverride<
C,
Fn extends (...a: any) => any,
> = Fn extends (...args: infer P) => infer R
? ((this: C, ...args: P) => R) & {
bind(context: C): (...args: P) => R
}
: never
export function isIp(hostname: string) {
// IPv4
if (hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) return true
// IPv6
if (hostname.startsWith('[') && hostname.endsWith(']')) return true
return false
}
const plainObjectProto = Object.prototype
export const ifObject = <V>(v: V) => {
if (typeof v === 'object' && v != null && !Array.isArray(v)) {
const proto = Object.getPrototypeOf(v)
if (proto === null || proto === plainObjectProto) {
// eslint-disable-next-line @typescript-eslint/ban-types
return v as V extends JsonScalar | JsonArray | Function | symbol
? never
: V extends Json
? V
: // Plain object are (mostly) safe to access using a string index
Record<string, unknown>
}
}
return undefined
}
export const ifString = <V>(v: V) => (typeof v === 'string' ? v : undefined)
export class MaxBytesTransformStream extends TransformStream<
Uint8Array,
Uint8Array
> {
constructor(maxBytes: number) {
// Note: negation accounts for invalid value types (NaN, non numbers)
if (!(maxBytes >= 0)) {
throw new TypeError('maxBytes must be a non-negative number')
}
let bytesRead = 0
super({
transform: (
chunk: Uint8Array,
ctrl: TransformStreamDefaultController<Uint8Array>,
) => {
if ((bytesRead += chunk.length) <= maxBytes) {
ctrl.enqueue(chunk)
} else {
ctrl.error(new Error('Response too large'))
}
},
})
}
}
const LINE_BREAK = /\r?\n/g
export function padLines(input: string, pad: string) {
if (!input) return input
return pad + input.replace(LINE_BREAK, `$&${pad}`)
}
/**
* @param [onCancellationError] - Callback that will trigger to asynchronously
* handle any error that occurs while cancelling the response body. Providing
* this will speed up the process and avoid potential deadlocks. Defaults to
* awaiting the cancellation operation. use `"log"` to log the error.
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
* @note awaiting this function's result, when no `onCancellationError` is
* provided, might result in a dead lock. Indeed, if the response was cloned(),
* the response.body.cancel() method will not resolve until the other response's
* body is consumed/cancelled.
*
* @example
* ```ts
* // Make sure response was not cloned, or that every cloned response was
* // consumed/cancelled before awaiting this function's result.
* await cancelBody(response)
* ```
* @example
* ```ts
* await cancelBody(response, (err) => {
* // No biggie, let's just log the error
* console.warn('Failed to cancel response body', err)
* })
* ```
* @example
* ```ts
* // Will generate an "unhandledRejection" if an error occurs while cancelling
* // the response body. This will likely crash the process.
* await cancelBody(response, (err) => { throw err })
* ```
*/
export async function cancelBody(
body: Body,
onCancellationError?: 'log' | ((err: unknown) => void),
): Promise<void> {
if (
body.body &&
!body.bodyUsed &&
!body.body.locked &&
// Support for alternative fetch implementations
typeof body.body.cancel === 'function'
) {
if (typeof onCancellationError === 'function') {
void body.body.cancel().catch(onCancellationError)
} else if (onCancellationError === 'log') {
void body.body.cancel().catch(logCancellationError)
} else {
await body.body.cancel()
}
}
}
export function logCancellationError(err: unknown): void {
console.warn('Failed to cancel response body', err)
}
export async function stringifyMessage(input: Body & { headers: Headers }) {
try {
const headers = stringifyHeaders(input.headers)
const payload = await stringifyBody(input)
return headers && payload ? `${headers}\n${payload}` : headers || payload
} finally {
void cancelBody(input, 'log')
}
}
function stringifyHeaders(headers: Headers) {
return Array.from(headers)
.map(([name, value]) => `${name}: ${value}`)
.join('\n')
}
async function stringifyBody(body: Body) {
try {
const blob = await body.blob()
if (blob.type?.startsWith('text/')) {
const text = await blob.text()
return JSON.stringify(text)
}
if (/application\/(?:\w+\+)?json/.test(blob.type)) {
const text = await blob.text()
return text.includes('\n') ? JSON.stringify(JSON.parse(text)) : text
}
return `[Body size: ${blob.size}, type: ${JSON.stringify(blob.type)} ]`
} catch {
return '[Body could not be read]'
}
}

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/isomorphic.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,39 @@
{
"name": "@atproto-labs/handle-resolver-node",
"version": "0.0.1",
"license": "MIT",
"description": "Node specific ATProto handle to DID resolver",
"keywords": [
"atproto",
"oauth",
"handle",
"identity",
"node"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/handle-resolver-node"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/fetch-node": "workspace:*",
"@atproto-labs/handle-resolver": "workspace:*",
"@atproto/did": "workspace:*"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,51 @@
import { Fetch, safeFetchWrap } from '@atproto-labs/fetch-node'
import {
AtprotoHandleResolver,
HandleResolver,
} from '@atproto-labs/handle-resolver'
import {
nodeResolveTxtDefault,
nodeResolveTxtFactory,
} from './node-resolve-txt-factory.js'
export type AtprotoHandleResolverNodeOptions = {
/**
* List of backup nameservers to use in case the primary ones fail. Will
* default to no fallback nameservers.
*/
fallbackNameservers?: string[]
/**
* Fetch function to use for HTTP requests. Allows customizing the request
* behavior, e.g. adding headers, setting a timeout, mocking, etc. The
* provided fetch function will be wrapped with a safeFetchWrap function that
* adds SSRF protection.
*
* @default `globalThis.fetch`
*/
fetch?: Fetch
}
export class AtprotoHandleResolverNode
extends AtprotoHandleResolver
implements HandleResolver
{
constructor({
fetch = globalThis.fetch,
fallbackNameservers,
}: AtprotoHandleResolverNodeOptions = {}) {
super({
fetch: safeFetchWrap({
fetch,
timeout: 3000, // 3 seconds
ssrfProtection: true,
responseMaxSize: 10 * 1048, // DID are max 2048 characters, 10kb for safety
}),
resolveTxt: nodeResolveTxtDefault,
resolveTxtFallback: fallbackNameservers?.length
? nodeResolveTxtFactory(fallbackNameservers)
: undefined,
})
}
}

View File

@ -0,0 +1,4 @@
// Main export
export * from './atproto-handle-resolver-node.js'
export * from './node-resolve-txt-factory.js'
export { AtprotoHandleResolverNode as default } from './atproto-handle-resolver-node.js'

View File

@ -0,0 +1,100 @@
import { Resolver, lookup, resolveTxt } from 'node:dns/promises'
import { isIP } from 'node:net'
import { ResolveTxt } from '@atproto-labs/handle-resolver'
export const nodeResolveTxtDefault: ResolveTxt = (hostname) =>
resolveTxt(hostname).then(groupChunks, handleError)
export function nodeResolveTxtFactory(nameservers: string[]): ResolveTxt {
// Optimization
if (!nameservers.length) return async () => null
// Build the resolver asynchronously (will be awaited on every use)
const resolverPromise: Promise<Resolver | null> = Promise.all<string[]>(
nameservers.map((nameserver) => {
const [domain, port = null] = nameserver.split(':', 2)
if (port !== null && !/^\d+$/.test(port)) {
throw new TypeError(`Invalid name server "${nameserver}"`)
}
return isIP(domain) === 4 || isBracedIPv6(domain)
? [nameserver] // No need to lookup
: lookup(domain, { all: true }).then(
(r) => r.map((a) => appendPort(a.address, port)),
// Let's just ignore failed nameservers resolution
(_err) => [],
)
}),
).then((results) => {
const backupIps = results.flat(1)
// No resolver if no valid IP
if (!backupIps.length) return null
const resolver = new Resolver()
resolver.setServers(backupIps)
return resolver
})
// Avoid uncaught promise rejection
void resolverPromise.catch(() => {
// Should never happen though...
})
return async (hostname) => {
const resolver = await resolverPromise
return resolver
? resolver.resolveTxt(hostname).then(groupChunks, handleError)
: null
}
}
function isBracedIPv6(address: string): boolean {
return (
address.startsWith('[') &&
address.endsWith(']') &&
isIP(address.slice(1, -1)) === 6
)
}
function groupChunks(results: string[][]): string[] {
return results.map((chunks) => chunks.join(''))
}
function handleError(err: unknown) {
// Invalid argument type (e.g. hostname is a number)
if (err instanceof TypeError) throw err
// If the hostname does not resolve, return null
if (err instanceof Error) {
if (err['code'] === 'ENOTFOUND') return null
// Hostname is not a valid domain name
if (err['code'] === 'EBADNAME') throw err
// DNS server unreachable
// if (err['code'] === 'ETIMEOUT') throw err
}
// Historically, errors were not thrown here. A "null" value indicates to the
// AtprotoHandleResolver that it should try the fallback resolver.
// @TODO We might want to re-visit this to only apply when an unexpected error
// occurs (by throwing here). For now, let's keep the same behavior as before.
// throw err
return null
}
function appendPort(address: string, port: string | null): string {
switch (isIP(address)) {
case 4:
return port ? `${address}:${port}` : address
case 6:
return port ? `[${address}]:${port}` : `[${address}]`
default:
throw new TypeError(`Invalid IP address "${address}"`)
}
}

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/node.json"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,148 @@
# Universal Handle Resolver implementation for ATPROTO
This package provides a handle resolver implementation for ATPROTO. It is used
to resolve handles to their corresponding DID.
This package is meant to be used in any JavaScript environment that support the
`fetch()` function. Because APTORO handle resolution requires DNS resolution,
you will need to provide your own DNS resolution function when using this
package.
There are two main classes in this package:
- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution
algorithm (and requires a DNS resolver).
- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView
(bsky.app) to provide handle resolution.
## Usage
### From a front-end app
Since the ATPROTO handle resolution algorithm requires DNS resolution, and the
browser does not provide a built-in DNS resolver, this package offers two
options:
- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is
the recommended approach for front-end apps.
- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method
if you don't own an AppView and already have a DoH server that you trust.
Using an AppView:
> [!CAUTION]
> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS
> (`https://bsky.social/`), at your own risk. Using these servers in a
> third-party application might expose your users' data (IP address) to Bluesky.
> Bluesky might log the data sent to it when your app is resolving handles.
> Bluesky might also change the API, or terms or use, at any time without
> notice. Make sure you are compliant with the Bluesky terms of use as well as
> any laws and regulations that apply to your use case.
```ts
import { AppViewHandleResolver } from '@atproto-labs/handle-resolver'
const resolver = new AppViewHandleResolver({
service: 'https://my-app-view.com/',
})
const did = await resolver.resolve('my-handle.bsky.social')
```
Using DNS-over-HTTPS (DoH) for DNS resolution:
> [!CAUTION]
> Using a DoH server that you don't own might expose your users' data to
> the DoH server provider. The DoH server provider might log the data sent to it
> by your app, allowing them to track which handles are being resolved by your
> users. In the browser, it is recommended to use a DoH server that you own and
> control. Or to implement your own AppView and use the `AppViewHandleResolver`
> class.
> [!NOTE]
> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that
> supports the DNS-over-HTTPS protocol with "application/dns-json" responses.
```ts
import { DohHandleResolver } from '@atproto-labs/handle-resolver'
// Also works with 'https://cloudflare-dns.com/dns-query'
const resolver = new DohHandleResolver('https://dns.google/resolve', {
// Optional: Custom fetch function that will be used both for DNS resolution
// and well-known resolution.
fetch: globalThis.fetch.bind(globalThis),
})
const did = await resolver.resolve('my-handle.bsky.social')
```
### From a Node.js app
> [!NOTE]
> On a Node.js backend, you will probably want to use the
> "@atproto-labs/handle-resolver-node" package. The example below applies to
> Node.js code running on a user's machine (e.g. through Electron).
```ts
import { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'
import { resolveTxt } from 'node:dns/promises'
const resolver = new AtprotoHandleResolver({
// Optional: Custom fetch function (used for well-known resolution)
fetch: globalThis.fetch.bind(globalThis),
resolveTxt: async (domain: string) =>
resolveTxt(domain).then((chunks) => chunks.join('')),
})
```
### Caching
Using a default, in-memory cache, in which items expire after 10 minutes:
```ts
import {
AppViewHandleResolver,
CachedHandleResolver,
HandleResolver,
HandleCache,
} from '@atproto-labs/handle-resolver'
// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver
const resolver = new CachedHandleResolver(sourceResolver)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
```
Using a custom cache:
```ts
import {
AppViewHandleResolver,
CachedHandleResolver,
HandleResolver,
HandleCache,
} from '@atproto-labs/handle-resolver'
// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver
const cache: HandleCache = {
set(handle, did): Promise<void> {
/* TODO */
},
get(handle): Promise<undefined | string> {
/* TODO */
},
del(handle): Promise<void> {
/* TODO */
},
}
const resolver = new CachedHandleResolver(sourceResolver, cache)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
```

View File

@ -0,0 +1,42 @@
{
"name": "@atproto-labs/handle-resolver",
"version": "0.0.1",
"license": "MIT",
"description": "Isomorphic ATProto handle to DID resolver",
"keywords": [
"atproto",
"oauth",
"handle",
"identity",
"browser",
"node",
"isomorphic"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/handle-resolver"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/simple-store": "workspace:*",
"@atproto-labs/simple-store-memory": "workspace:*",
"@atproto/did": "workspace:*",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,95 @@
import z from 'zod'
import {
HandleResolver,
ResolveOptions,
ResolvedHandle,
isResolvedHandle,
} from './types.js'
export const xrpcErrorSchema = z.object({
error: z.string(),
message: z.string().optional(),
})
export type AppViewHandleResolverOptions = {
/**
* Fetch function to use for HTTP requests. Allows customizing the request
* behavior, e.g. adding headers, setting a timeout, mocking, etc.
*
* @default globalThis.fetch
*/
fetch?: typeof globalThis.fetch
}
export class AppViewHandleResolver implements HandleResolver {
static from(
service: URL | string | HandleResolver,
options?: AppViewHandleResolverOptions,
): HandleResolver {
if (typeof service === 'string' || service instanceof URL) {
return new AppViewHandleResolver(service, options)
}
return service
}
/**
* URL of the atproto lexicon server. This is the base URL where the
* `com.atproto.identity.resolveHandle` XRPC method is located.
*/
protected readonly serviceUrl: URL
protected readonly fetch: typeof globalThis.fetch
constructor(service: URL | string, options?: AppViewHandleResolverOptions) {
this.serviceUrl = new URL(service)
this.fetch = options?.fetch ?? globalThis.fetch
}
public async resolve(
handle: string,
options?: ResolveOptions,
): Promise<ResolvedHandle> {
const url = new URL(
'/xrpc/com.atproto.identity.resolveHandle',
this.serviceUrl,
)
url.searchParams.set('handle', handle)
const headers = new Headers()
if (options?.noCache) headers.set('cache-control', 'no-cache')
const response = await this.fetch.call(null, url, {
headers,
signal: options?.signal,
redirect: 'error',
})
const payload = await response.json()
// The response should either be
// - 400 Bad Request with { error: 'InvalidRequest', message: 'Unable to resolve handle' }
// - 200 OK with { did: NonNullable<ResolvedHandle> }
// Any other response is considered unexpected behavior an should throw an error.
if (response.status === 400) {
const data = xrpcErrorSchema.parse(payload)
if (
data.error === 'InvalidRequest' &&
data.message === 'Unable to resolve handle'
) {
return null
}
}
if (!response.ok) {
throw new TypeError('Invalid response from resolveHandle method')
}
const value: unknown = payload?.did
if (!isResolvedHandle(value)) {
throw new TypeError('Invalid DID returned from resolveHandle method')
}
return value
}
}

View File

@ -0,0 +1,123 @@
import {
AtprotoHandleResolver,
AtprotoHandleResolverOptions,
} from './atproto-handle-resolver.js'
import { HandleResolver } from './types.js'
import { ResolveTxt } from './internal-resolvers/dns-handle-resolver.js'
export type AtprotoDohHandleResolverOptions = Omit<
AtprotoHandleResolverOptions,
'resolveTxt' | 'resolveTxtFallback'
> & {
dohEndpoint: string | URL
}
export class AtprotoDohHandleResolver
extends AtprotoHandleResolver
implements HandleResolver
{
constructor(options: AtprotoDohHandleResolverOptions) {
super({
...options,
resolveTxt: dohResolveTxtFactory(options),
resolveTxtFallback: undefined,
})
}
}
/**
* Resolver for DNS-over-HTTPS (DoH) handles. Only works with servers supporting
* Google Flavoured "application/dns-json" queries.
*
* @see {@link https://developers.google.com/speed/public-dns/docs/doh/json}
* @see {@link https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/}
* @todo Add support for DoH using application/dns-message (?)
*/
function dohResolveTxtFactory({
dohEndpoint,
fetch = globalThis.fetch,
}: AtprotoDohHandleResolverOptions): ResolveTxt {
return async (hostname) => {
const url = new URL(dohEndpoint)
url.searchParams.set('type', 'TXT')
url.searchParams.set('name', hostname)
const response = await fetch(url, {
method: 'GET',
headers: { accept: 'application/dns-json' },
redirect: 'follow',
})
try {
const contentType = response.headers.get('content-type')?.trim()
if (!response.ok) {
const message = contentType?.startsWith('text/plain')
? await response.text()
: `Failed to resolve ${hostname}`
throw new TypeError(message)
} else if (contentType !== 'application/dns-json') {
throw new TypeError('Unexpected response from DoH server')
}
const result = asResult(await response.json())
return result.Answer?.filter(isAnswerTxt).map(extractTxtData) ?? null
} finally {
// Make sure to always cancel the response body as some engines (Node 👀)
// do not do this automatically.
// https://undici.nodejs.org/#/?id=garbage-collection
if (response.bodyUsed === false) {
// Handle rejection asynchronously
void response.body?.cancel().catch(onCancelError)
}
}
}
}
function onCancelError(err: unknown) {
if (!(err instanceof DOMException) || err.name !== 'AbortError') {
console.error('An error occurred while cancelling the response body:', err)
}
}
type Result = { Status: number; Answer?: Answer[] }
function isResult(result: unknown): result is Result {
if (typeof result !== 'object' || result === null) return false
if (!('Status' in result) || typeof result.Status !== 'number') return false
if ('Answer' in result && !isArrayOf(result.Answer, isAnswer)) return false
return true
}
function asResult(result: unknown): Result {
if (isResult(result)) return result
throw new TypeError('Invalid DoH response')
}
function isArrayOf<T>(
value: unknown,
predicate: (v: unknown) => v is T,
): value is T[] {
return Array.isArray(value) && value.every(predicate)
}
type Answer = { name: string; type: number; data: string; TTL: number }
function isAnswer(answer: unknown): answer is Answer {
return (
typeof answer === 'object' &&
answer !== null &&
'name' in answer &&
typeof answer.name === 'string' &&
'type' in answer &&
typeof answer.type === 'number' &&
'data' in answer &&
typeof answer.data === 'string' &&
'TTL' in answer &&
typeof answer.TTL === 'number'
)
}
type AnswerTxt = Answer & { type: 16 }
function isAnswerTxt(answer: Answer): answer is AnswerTxt {
return answer.type === 16
}
function extractTxtData(answer: AnswerTxt): string {
return answer.data.replace(/^"|"$/g, '').replace(/\\"/g, '"')
}

View File

@ -0,0 +1,75 @@
import {
DnsHandleResolver,
ResolveTxt,
} from './internal-resolvers/dns-handle-resolver.js'
import {
WellKnownHandleResolver,
WellKnownHandleResolverOptions,
} from './internal-resolvers/well-known-handler-resolver.js'
import { HandleResolver, ResolveOptions, ResolvedHandle } from './types.js'
export type { ResolveTxt }
export type AtprotoHandleResolverOptions = WellKnownHandleResolverOptions & {
resolveTxt: ResolveTxt
resolveTxtFallback?: ResolveTxt
}
const noop = () => {}
/**
* Implementation of the official ATPROTO handle resolution strategy.
* This implementation relies on two primitives:
* - HTTP Well-Known URI resolution (requires a `fetch()` implementation)
* - DNS TXT record resolution (requires a `resolveTxt()` function)
*/
export class AtprotoHandleResolver implements HandleResolver {
private readonly httpResolver: HandleResolver
private readonly dnsResolver: HandleResolver
private readonly dnsResolverFallback?: HandleResolver
constructor(options: AtprotoHandleResolverOptions) {
this.httpResolver = new WellKnownHandleResolver(options)
this.dnsResolver = new DnsHandleResolver(options.resolveTxt)
this.dnsResolverFallback = options.resolveTxtFallback
? new DnsHandleResolver(options.resolveTxtFallback)
: undefined
}
async resolve(
handle: string,
options?: ResolveOptions,
): Promise<ResolvedHandle> {
options?.signal?.throwIfAborted()
const abortController = new AbortController()
const { signal } = abortController
options?.signal?.addEventListener('abort', () => abortController.abort(), {
signal,
})
const wrappedOptions = { ...options, signal }
try {
const dnsPromise = this.dnsResolver.resolve(handle, wrappedOptions)
const httpPromise = this.httpResolver.resolve(handle, wrappedOptions)
// Prevent uncaught promise rejection
httpPromise.catch(noop)
const dnsRes = await dnsPromise
if (dnsRes) return dnsRes
signal.throwIfAborted()
const res = await httpPromise
if (res) return res
signal.throwIfAborted()
return this.dnsResolverFallback?.resolve(handle, wrappedOptions) ?? null
} finally {
// Cancel pending requests, and remove "abort" listener on incoming signal
abortController.abort()
}
}
}

View File

@ -0,0 +1,32 @@
import { CachedGetter, SimpleStore } from '@atproto-labs/simple-store'
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
import { ResolveOptions, HandleResolver, ResolvedHandle } from './types.js'
export type HandleCache = SimpleStore<string, ResolvedHandle>
export class CachedHandleResolver implements HandleResolver {
private getter: CachedGetter<string, ResolvedHandle>
constructor(
/**
* The resolver that will be used to resolve handles.
*/
resolver: HandleResolver,
cache: HandleCache = new SimpleStoreMemory<string, ResolvedHandle>({
max: 1000,
ttl: 10 * 60e3,
}),
) {
this.getter = new CachedGetter<string, ResolvedHandle>(
(handle, options) => resolver.resolve(handle, options),
cache,
)
}
async resolve(
handle: string,
options?: ResolveOptions,
): Promise<ResolvedHandle> {
return this.getter.get(handle, options)
}
}

View File

@ -0,0 +1,9 @@
export * from './types.js'
// Main Handle Resolvers strategies
export * from './app-view-handle-resolver.js'
export * from './atproto-doh-handle-resolver.js'
export * from './atproto-handle-resolver.js'
// Handle Resolver Caching utility
export * from './cached-handle-resolver.js'

View File

@ -0,0 +1,38 @@
import { HandleResolver, ResolvedHandle, isResolvedHandle } from '../types'
const SUBDOMAIN = '_atproto'
const PREFIX = 'did='
/**
* DNS TXT record resolver. Return `null` if the hostname successfully does not
* resolve to a valid DID. Throw an error if an unexpected error occurs.
*/
export type ResolveTxt = (hostname: string) => Promise<null | string[]>
export class DnsHandleResolver implements HandleResolver {
constructor(protected resolveTxt: ResolveTxt) {}
async resolve(handle: string): Promise<ResolvedHandle> {
const results = await this.resolveTxt.call(null, `${SUBDOMAIN}.${handle}`)
if (!results) return null
for (let i = 0; i < results.length; i++) {
// If the line does not start with "did=", skip it
if (!results[i].startsWith(PREFIX)) continue
// Ensure no other entry starting with "did=" follows
for (let j = i + 1; j < results.length; j++) {
if (results[j].startsWith(PREFIX)) return null
}
// Note: No trimming (to be consistent with spec)
const did = results[i].slice(PREFIX.length)
// Invalid DBS record
return isResolvedHandle(did) ? did : null
}
return null
}
}

View File

@ -0,0 +1,56 @@
import {
ResolveOptions,
HandleResolver,
ResolvedHandle,
isResolvedHandle,
} from '../types.js'
export type WellKnownHandleResolverOptions = {
/**
* Fetch function to use for HTTP requests. Allows customizing the request
* behavior, e.g. adding headers, setting a timeout, mocking, etc. The
* provided fetch function will be wrapped with a safeFetchWrap function that
* adds SSRF protection.
*
* @default `globalThis.fetch`
*/
fetch?: typeof globalThis.fetch
}
export class WellKnownHandleResolver implements HandleResolver {
protected readonly fetch: typeof globalThis.fetch
constructor(options?: WellKnownHandleResolverOptions) {
this.fetch = options?.fetch ?? globalThis.fetch
}
public async resolve(
handle: string,
options?: ResolveOptions,
): Promise<ResolvedHandle> {
const url = new URL('/.well-known/atproto-did', `https://${handle}`)
const headers = new Headers()
if (options?.noCache) headers.set('cache-control', 'no-cache')
try {
const response = await this.fetch.call(null, url, {
headers,
signal: options?.signal,
redirect: 'error',
})
const text = await response.text()
const firstLine = text.split('\n')[0]!.trim()
if (isResolvedHandle(firstLine)) return firstLine
return null
} catch (err) {
// The the request failed, assume the handle does not resolve to a DID,
// unless the failure was due to the signal being aborted.
options?.signal?.throwIfAborted()
return null
}
}
}

View File

@ -0,0 +1,33 @@
import { Did, isAtprotoDidWeb, isDidPlc } from '@atproto/did'
export type ResolveOptions = {
signal?: AbortSignal
noCache?: boolean
}
/**
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
*/
export type ResolvedHandle = null | Did<'plc' | 'web'>
export { type Did }
/**
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
*/
export function isResolvedHandle<T = unknown>(
value: T,
): value is T & ResolvedHandle {
return value === null || isDidPlc(value) || isAtprotoDidWeb(value)
}
export interface HandleResolver {
/**
* @returns the DID that corresponds to the given handle, or `null` if no DID
* is found. `null` should only be returned if no unexpected behavior occurred
* during the resolution process.
* @throws Error if the resolution method fails due to an unexpected error, or
* if the resolution is aborted ({@link ResolveOptions}).
*/
resolve(handle: string, options?: ResolveOptions): Promise<ResolvedHandle>
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig/isomorphic.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,38 @@
{
"name": "@atproto-labs/identity-resolver",
"version": "0.0.1",
"license": "MIT",
"description": "A library resolving ATPROTO identities",
"keywords": [
"atproto",
"identity",
"isomorphic",
"resolver"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/identity-resolver"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/did-resolver": "workspace:*",
"@atproto-labs/handle-resolver": "workspace:*",
"@atproto/syntax": "workspace:*"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}
}

View File

@ -0,0 +1,78 @@
import {
Did,
DidDocument,
ResolveOptions as DidResolveOptions,
DidResolver,
DidService,
} from '@atproto-labs/did-resolver'
import {
ResolveOptions as HandleResolveOptions,
HandleResolver,
ResolvedHandle,
isResolvedHandle,
} from '@atproto-labs/handle-resolver'
import { normalizeAndEnsureValidHandle } from '@atproto/syntax'
export type ResolvedIdentity = {
did: NonNullable<ResolvedHandle>
pds: URL
}
export type ResolveOptions = DidResolveOptions & HandleResolveOptions
export class IdentityResolver {
constructor(
readonly didResolver: DidResolver<'plc' | 'web'>,
readonly handleResolver: HandleResolver,
) {}
public async resolve(
input: string,
options?: ResolveOptions,
): Promise<ResolvedIdentity> {
const did = isResolvedHandle(input)
? input // Already a did
: await this.handleResolver.resolve(
normalizeAndEnsureValidHandle(input),
options,
)
options?.signal?.throwIfAborted()
if (!did) {
throw new TypeError(`Handle "${input}" does not resolve to a DID`)
}
const document = await this.didResolver.resolve(did, options)
const service = document.service?.find(
isAtprotoPersonalDataServerService<'plc' | 'web'>,
document,
)
if (!service) {
throw new TypeError(
`No valid "AtprotoPersonalDataServer" service found in "${did}" DID document`,
)
}
const pds = new URL(service.serviceEndpoint)
return { did, pds }
}
}
function isAtprotoPersonalDataServerService<M extends string>(
this: DidDocument<M>,
s: DidService,
): s is {
id: '#atproto_pds' | `${Did<M>}#atproto_pds`
type: 'AtprotoPersonalDataServer'
serviceEndpoint: string
} {
return (
typeof s.serviceEndpoint === 'string' &&
s.type === 'AtprotoPersonalDataServer' &&
(s.id === '#atproto_pds' || s.id === `${this.id}#atproto_pds`)
)
}

View File

@ -0,0 +1 @@
export * from './identity-resolver.js'

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/isomorphic.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,32 @@
{
"name": "@atproto-labs/pipe",
"version": "0.0.1",
"license": "MIT",
"description": "Library for combining multiple functions into a single function.",
"keywords": [
"atproto",
"transformer"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/pipe"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}
}

View File

@ -0,0 +1,2 @@
export { pipe, pipeTwo } from './pipe.js'
export { type Transformer } from './transformer.js'

View File

@ -0,0 +1,63 @@
import { Transformer } from './transformer.js'
type PipelineInput<T extends readonly Transformer<any>[]> = T extends [
Transformer<infer I, any>,
...any[],
]
? I
: T extends Transformer<infer I, any>[]
? I
: never
type PipelineOutput<T extends readonly Transformer<any>[]> = T extends [
...any[],
Transformer<any, infer O>,
]
? O
: T extends Transformer<any, infer O>[]
? O
: never
type Pipeline<
F extends readonly Transformer<any>[],
Acc extends readonly Transformer<any>[] = [],
> = F extends [Transformer<infer I, infer O>]
? [...Acc, Transformer<I, O>]
: F extends [Transformer<infer A, any>, ...infer Tail]
? Tail extends [Transformer<infer B, any>, ...any[]]
? Pipeline<Tail, [...Acc, Transformer<A, B>]>
: Acc
: Acc
/**
* This utility function allows to properly type a pipeline of transformers.
*
* @example
* ```ts
* // Will be typed as "(input: string) => Promise<number>"
* const parse = pipe(
* async (input: string) => JSON.parse(input),
* async (input: unknown) => {
* if (typeof input === 'number') return input
* throw new TypeError('Invalid input')
* },
* (input: number) => input * 2,
* )
* ```
*/
export function pipe(): never
export function pipe<T extends readonly Transformer<any>[]>(
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>>
export function pipe<T extends readonly Transformer<any>[]>(
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>> {
return pipeline.reduce(pipeTwo)
}
export function pipeTwo<I, O, X = unknown>(
first: Transformer<I, X>,
second: Transformer<X, O>,
): (input: I) => Promise<O> {
return async (input) => second(await first(input))
}

View File

@ -0,0 +1 @@
export type Transformer<I, O = I> = (input: I) => O | PromiseLike<O>

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/isomorphic.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,99 @@
# @atproto-labs/rollup-plugin-bundle-manifest
This Rollup plugin allows to generate a (JSON) manifest containing the output
files of a Rollup build. The manifest will look as follows:
```json
{
"main.js": {
"type": "chunk",
"mime": "application/javascript",
"dynamicImports": [],
"isDynamicEntry": false,
"isEntry": true,
"isImplicitEntry": false,
"name": "main",
"sha256": "<sha256-hash>",
"data": "<base64-encoded-contents>"
},
"main.js.map": {
"type": "asset",
"mime": "application/json",
"sha256": "<sha256-hash>",
"data": "<base64-encoded-contents>"
},
"main.css": {
"type": "asset",
"mime": "text/css",
"sha256": "<sha256-hash>",
"data": "<base64-encoded-contents>"
}
// ... more entries as needed
}
```
This manifest will typically be useful for a backend service that serves the
frontend assets, as it can be used to determine the correct `Content-Type` and
and file integrity (via the SHA-256 hash), without having to read the files
themselves.
## Usage
```js
// rollup.config.js
import bundleManifest from '@atproto-labs/rollup-plugin-bundle-manifest'
export default {
input: 'src/index.js',
output: {
dir: 'dist',
format: 'es',
},
plugins: [
bundleManifest({
name: 'bundle-manifest.json',
// Optional: should the asset data be embedded (as base64 string) in the manifest?
data: false,
}),
],
}
```
## Options
- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.
- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.
## Example
```js
const assetManifest = require('./dist/bundle-manifest.json')
const app = express()
app.use((req, res, next) => {
const asset = assetManifest[req.path.slice(1)]
if (!asset) return next()
res.setHeader('Content-Type', asset.mime)
res.setHeader('Content-Length', asset.data.length)
res.end(Buffer.from(asset.data, 'base64'))
})
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
buildCSP(assetManifest), // Not provided here
)
// Serve the index.html file
res.sendFile('index.html')
})
```
## License
MIT

View File

@ -0,0 +1,39 @@
{
"name": "@atproto-labs/rollup-plugin-bundle-manifest",
"version": "0.0.1",
"license": "MIT",
"description": "Library for generating a manifest of bundled files from a Rollup build",
"keywords": [
"atproto",
"rollup",
"manifest"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/rollup-plugin-bundle-manifest"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"mime": "^3.0.0"
},
"peerDependencies": {
"rollup": "^4.0.0"
},
"devDependencies": {
"rollup": "^4.10.0",
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}
}

View File

@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { extname } from 'node:path'
import mime from 'mime'
import { Plugin } from 'rollup'
type AssetItem = {
type: 'asset'
mime?: string
sha256: string
data?: string
}
type ChunkItem = {
type: 'chunk'
mime: string
sha256: string
dynamicImports: string[]
isDynamicEntry: boolean
isEntry: boolean
isImplicitEntry: boolean
name: string
data?: string
}
export type ManifestItem = AssetItem | ChunkItem
export type Manifest = Record<string, ManifestItem>
export default function bundleManifest({
name = 'bundle-manifest.json',
data = false,
}: {
name?: string
data?: boolean
} = {}): Plugin {
return {
name: 'bundle-manifest',
generateBundle(outputOptions, bundle) {
const manifest: Manifest = {}
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'asset') {
manifest[fileName] = {
type: chunk.type,
data: data
? Buffer.from(chunk.source).toString('base64')
: undefined,
mime: mime.getType(extname(fileName)) || undefined,
sha256: createHash('sha256').update(chunk.source).digest('base64'),
}
}
if (chunk.type === 'chunk') {
manifest[fileName] = {
type: chunk.type,
data: data ? Buffer.from(chunk.code).toString('base64') : undefined,
mime: 'application/javascript',
sha256: createHash('sha256').update(chunk.code).digest('base64'),
dynamicImports: chunk.dynamicImports,
isDynamicEntry: chunk.isDynamicEntry,
isEntry: chunk.isEntry,
isImplicitEntry: chunk.isImplicitEntry,
name: chunk.name,
}
}
}
this.emitFile({
type: 'asset',
fileName: name,
source: JSON.stringify(manifest, null, 2),
})
},
}
}

View File

@ -0,0 +1,8 @@
{
"extends": ["../../../tsconfig/node.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,36 @@
{
"name": "@atproto-labs/simple-store-memory",
"version": "0.0.1",
"license": "MIT",
"description": "Memory based simple-store implementation",
"keywords": [
"cache",
"isomorphic",
"memory"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/simple-store-memory"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@atproto-labs/simple-store": "workspace:*",
"lru-cache": "^10.2.0"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,99 @@
import { SimpleStore, Key, Value } from '@atproto-labs/simple-store'
import { LRUCache } from 'lru-cache'
import { roughSizeOfObject } from './util.js'
export type SimpleStoreMemoryOptions<K extends Key, V extends Value> = {
/**
* The maximum number of entries in the cache.
*/
max?: number
/**
* The time-to-live of a cache entry, in milliseconds.
*/
ttl?: number
/**
* Whether to automatically prune expired entries.
*/
ttlAutopurge?: boolean
/**
* The maximum total size of the cache, in units defined by the sizeCalculation
* function.
*
* @default No limit
*/
maxSize?: number
/**
* The maximum size of a single cache entry, in units defined by the
* sizeCalculation function.
*
* @default No limit
*/
maxEntrySize?: number
/**
* A function that returns the size of a value. The size is used to determine
* when the cache should be pruned, based on `maxSize`.
*
* @default The (rough) size in bytes used in memory.
*/
sizeCalculation?: (value: V, key: K) => number
} & ( // Memory is not infinite, so at least one pruning option is required.
| { max: number }
| { maxSize: number }
| { ttl: number; ttlAutopurge: boolean }
)
// LRUCache does not allow storing "null", so we use a symbol to represent it.
const nullSymbol = Symbol('nullItem')
type AsLruValue<V extends Value> = V extends null
? typeof nullSymbol
: Exclude<V, null>
const toLruValue = <V extends Value>(value: V) =>
(value === null ? nullSymbol : value) as AsLruValue<V>
const fromLruValue = <V extends Value>(value: AsLruValue<V>) =>
(value === nullSymbol ? null : value) as V
export class SimpleStoreMemory<K extends Key, V extends Value>
implements SimpleStore<K, V>
{
#cache: LRUCache<K, AsLruValue<V>>
constructor({ sizeCalculation, ...options }: SimpleStoreMemoryOptions<K, V>) {
this.#cache = new LRUCache<K, AsLruValue<V>>({
...options,
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
sizeCalculation: sizeCalculation
? (value, key) => sizeCalculation(fromLruValue(value), key)
: options.maxEntrySize != null || options.maxSize != null
? // maxEntrySize and maxSize require a size calculation function.
roughSizeOfObject
: undefined,
})
}
get(key: K): V | undefined {
const value = this.#cache.get(key)
if (value === undefined) return undefined
return fromLruValue(value)
}
set(key: K, value: V): void {
this.#cache.set(key, toLruValue(value))
}
del(key: K): void {
this.#cache.delete(key)
}
clear(): void {
this.#cache.clear()
}
}

View File

@ -0,0 +1,77 @@
const knownSizes = new WeakMap<object, number>()
/**
* @see {@link https://stackoverflow.com/a/11900218/356537}
*/
export function roughSizeOfObject(value: unknown): number {
const objectList = new Set()
const stack = [value] // This would be more efficient using a circular buffer
let bytes = 0
while (stack.length) {
const value = stack.pop()
// > All objects on the heap start with a shape descriptor, which takes one
// > pointer size (usually 4 bytes these days, thanks to "pointer
// > compression" on 64-bit platforms).
switch (typeof value) {
// Types are ordered by frequency
case 'string':
// https://stackoverflow.com/a/68791382/356537
bytes += 12 + 4 * Math.ceil(value.length / 4)
break
case 'number':
bytes += 12 // Shape descriptor + double
break
case 'boolean':
bytes += 4 // Shape descriptor
break
case 'object':
bytes += 4 // Shape descriptor
if (value === null) {
break
}
if (knownSizes.has(value)) {
bytes += knownSizes.get(value)!
break
}
if (objectList.has(value)) continue
objectList.add(value)
if (Array.isArray(value)) {
bytes += 4
stack.push(...value)
} else {
bytes += 8
const keys = Object.getOwnPropertyNames(value)
for (let i = 0; i < keys.length; i++) {
bytes += 4
const key = keys[i]
const val = value[key]
if (val !== undefined) stack.push(val)
stack.push(key)
}
}
break
case 'function':
bytes += 8 // Shape descriptor + pointer (assuming functions are shared)
break
case 'symbol':
bytes += 8 // Shape descriptor + pointer
break
case 'bigint':
bytes += 16 // Shape descriptor + BigInt
break
}
}
if (typeof value === 'object' && value !== null) {
knownSizes.set(value, bytes)
}
return bytes
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig/isomorphic.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src"]
}

View File

@ -0,0 +1,4 @@
{
"include": [],
"references": [{ "path": "./tsconfig.build.json" }]
}

View File

@ -0,0 +1,32 @@
{
"name": "@atproto-labs/simple-store",
"version": "0.0.1",
"license": "MIT",
"description": "Simple store interfaces & utilities",
"keywords": [
"cache",
"isomorphic"
],
"homepage": "https://atproto.com",
"repository": {
"type": "git",
"url": "https://github.com/bluesky-social/atproto",
"directory": "packages/internal/simple-store"
},
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc --build tsconfig.build.json"
}
}

View File

@ -0,0 +1,160 @@
import { Awaitable, SimpleStore, Key, Value } from './simple-store.js'
export type GetCachedOptions = {
signal?: AbortSignal
/**
* Do not use the cache to get the value. Always get a new value from the
* getter function.
*
* @default false
*/
noCache?: boolean
/**
* When getting a value from the cache, allow the value to be returned even if
* it is stale.
*
* Has no effect if the `isStale` option was not provided to the CachedGetter.
*
* @default true // If the CachedGetter has an isStale option
* @default false // If no isStale option was provided to the CachedGetter
*/
allowStale?: boolean
}
export type Getter<K, V> = (
key: K,
options: undefined | GetCachedOptions,
storedValue: undefined | V,
) => Awaitable<V>
export type CachedGetterOptions<K, V> = {
isStale?: (key: K, value: V) => boolean | PromiseLike<boolean>
onStoreError?: (err: unknown, key: K, value: V) => void | PromiseLike<void>
deleteOnError?: (
err: unknown,
key: K,
value: V,
) => boolean | PromiseLike<boolean>
}
type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>
const returnTrue = () => true
const returnFalse = () => false
/**
* Wrapper utility that uses a store to speed up the retrieval of values from an
* (expensive) getter function.
*/
export class CachedGetter<K extends Key = string, V extends Value = Value> {
private pending = new Map<K, PendingItem<V>>()
constructor(
readonly getter: Getter<K, V>,
readonly store: SimpleStore<K, V>,
readonly options?: Readonly<CachedGetterOptions<K, V>>,
) {}
async get(key: K, options?: GetCachedOptions): Promise<V> {
options?.signal?.throwIfAborted()
const isStale = this.options?.isStale
const allowStored: (value: V) => Awaitable<boolean> = options?.noCache
? returnFalse // Never allow stored values to be returned
: options?.allowStale || isStale == null
? returnTrue // Always allow stored values to be returned
: async (value: V) => !(await isStale(key, value))
// As long as concurrent requests are made for the same key, only one
// request will be made to the cache & getter function at a time. This works
// because there is no async operation between the while() loop and the
// pending.set() call. Because of the "single threaded" nature of
// JavaScript, the pending item will be set before the next iteration of the
// while loop.
let previousExecutionFlow: undefined | PendingItem<V>
while ((previousExecutionFlow = this.pending.get(key))) {
try {
const { isFresh, value } = await previousExecutionFlow
if (isFresh) return value
if (await allowStored(value)) return value
} catch {
// Ignore errors from previous execution flows (they will have been
// propagated by that flow).
}
options?.signal?.throwIfAborted()
}
const currentExecutionFlow: PendingItem<V> = Promise.resolve()
.then(async () => {
const storedValue = await this.getStored(key, options)
if (storedValue !== undefined && (await allowStored(storedValue))) {
// Use the stored value as return value for the current execution
// flow. Notify other concurrent execution flows (that should be
// "stuck" in the loop before until this promise resolves) that we got
// a value, but that it came from the store (isFresh = false).
return { isFresh: false, value: storedValue }
}
return Promise.resolve()
.then(async () => (0, this.getter)(key, options, storedValue))
.catch(async (err) => {
if (storedValue !== undefined) {
if (await this.options?.deleteOnError?.(err, key, storedValue)) {
await this.delStored(key)
}
}
throw err
})
.then(async (value) => {
// The value should be stored even is the signal was aborted.
await this.setStored(key, value)
return { isFresh: true, value }
})
})
.finally(() => {
this.pending.delete(key)
})
if (this.pending.has(key)) {
// This should never happen. Indeed, there must not be any 'await'
// statement between this and the loop iteration check meaning that
// this.pending.get returned undefined. It is there to catch bugs that
// would occur in future changes to the code.
throw new Error('Concurrent request for the same key')
}
this.pending.set(key, currentExecutionFlow)
const { value } = await currentExecutionFlow
return value
}
bind(key: K): (options?: GetCachedOptions) => Promise<V> {
return async (options) => this.get(key, options)
}
async getStored(key: K, options?: GetCachedOptions): Promise<V | undefined> {
try {
return await this.store.get(key, options)
} catch (err) {
return undefined
}
}
async setStored(key: K, value: V): Promise<void> {
try {
await this.store.set(key, value)
} catch (err) {
await this.options?.onStoreError?.(err, key, value)
}
}
async delStored(key: K): Promise<void> {
await this.store.del(key)
}
}

View File

@ -0,0 +1,2 @@
export * from './cached-getter.js'
export * from './simple-store.js'

View File

@ -0,0 +1,16 @@
export type Awaitable<V> = V | PromiseLike<V>
export type Key = string | number
export type Value = NonNullable<unknown> | null
export type GetOptions = { signal?: AbortSignal }
export interface SimpleStore<K extends Key = string, V extends Value = Value> {
/**
* @return undefined if the key is not in the store (which is why Value cannot contain "undefined").
*/
get: (key: K, options?: GetOptions) => Awaitable<undefined | V>
set: (key: K, value: V) => Awaitable<void>
del: (key: K) => Awaitable<void>
clear?: () => Awaitable<void>
}

Some files were not shown because too many files have changed in this diff Show More