Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed Dec 14, 2023
1 parent 84d9440 commit e3d6949
Show file tree
Hide file tree
Showing 14 changed files with 517 additions and 129 deletions.
4 changes: 3 additions & 1 deletion packages/oauth-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
"dependencies": {
"cookie": "^0.6.0",
"express": "^4.18.2",
"http-errors": "^2.0.0",
"ipaddr.js": "^2.1.0",
"kysely": "^0.22.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/express": "^4.17.13"
"@types/express": "^4.17.13",
"@types/http-errors": "^2.0.1"
}
}
31 changes: 31 additions & 0 deletions packages/oauth-server/src/client/client-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import createError from 'http-errors'
import { Fetch } from '../util/fetch'
import {
fetchResponseZodHandler,
fetchFailureHandler,
fetchResponseJsonHandler,
fetchResponseTypeHandler,
fetchSuccessHandler,
} from '../util/fetch-handlers'

import { ClientMetadata, clientMetadataValidator } from './types'

export async function fetchClientMetadata(
metadataEndpoint: URL,
fetchFn: Fetch = fetch,
): Promise<ClientMetadata> {
return fetchFn(new Request(metadataEndpoint, { redirect: 'error' }))
.then(fetchSuccessHandler(), fetchFailureHandler())
.then(fetchResponseTypeHandler(/^application\/json$/))
.then(fetchResponseJsonHandler())
.then(fetchResponseZodHandler(clientMetadataValidator))
}

export async function extractClientMetadataEndpoint({
body,
}: {
body: unknown
}): Promise<URL> {
// TODO: implement this
throw createError(404, 'No client metadata endpoint found in DID Document')
}
87 changes: 45 additions & 42 deletions packages/oauth-server/src/client/client-registry.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,55 @@
import { DidWeb, didWebDocumentUrl } from '../util/did-web'
import { ssrfSafeFetch } from '../util/fetch'
import { DidWeb, didWebToUrl, fetchDidDocument } from '../util/did-web'
import { Fetch, fetchFactory } from '../util/fetch'
import {
forbiddenDomainNameRequestTransform,
ssrfSafeRequestTransform,
} from '../util/fetch-transform'
import { isLoopbackHostname } from '../util/net'

import { extractService } from '../util/did'
import { fetchClientMetadata } from './client-metadata'
import { ClientMetadata, clientMetadataSchema } from './types'

import { ClientMetadata } from './types'

/**
* @todo TODO return better errors
*/
export class ClientRegistry {
constructor() {}

async getClientMetadata(clientId: DidWeb): Promise<ClientMetadata | null> {
const url = didWebDocumentUrl(clientId)

// Explicitely allow localhost
if (url.hostname === '127.0.0.1') return null
if (url.hostname === 'localhost') return null
if (url.hostname.endsWith('.localhost')) return null
if (url.hostname.endsWith('.local')) return null
protected readonly fetch: Fetch

const didDocument = await ssrfSafeFetch(url, {
mode: 'cors',
headers: {
accept: 'application/did+json',
},
})
.then(async (response) => {
if (
response.ok &&
response.headers.get('content-type') !== 'application/did+json'
) {
return response.json()
}
constructor(fetch?: Fetch) {
this.fetch = fetchFactory(fetch, [
ssrfSafeRequestTransform(),
forbiddenDomainNameRequestTransform(['bsky.social', 'bsky.network']),
])
}

// Consume the response. We dont care about the content
await response.blob()
async getClientMetadata(clientId: DidWeb): Promise<ClientMetadata> {
const url = didWebToUrl(clientId)

return null
// Allow localhost
if (isLoopbackHostname(url.hostname)) {
return clientMetadataSchema.parse({
client_name: 'Localhost',
redirect_uris: [url.toString()],
jwks: [],
token_endpoint_auth_method: 'none',
})
}

return fetchDidDocument(clientId, this.fetch)
.then(
(didDocument) =>
extractService(didDocument, 'OAuthClientMetadata')?.serviceEndpoint,
)
.catch((err) => {
// TODO: Make sure we forward error if needed
return null
// In case of 404, fallback to the well-known endpoint
if (err.status === 404) return undefined
throw err
})

if (!didDocument) return null

return {
clientId,
didDocument,
}
.then(async (metadataEndpoint) =>
fetchClientMetadata(
metadataEndpoint
? new URL(metadataEndpoint)
: new URL('/.well-known/oauth-client-metadata', url),
this.fetch,
),
)
}
}
12 changes: 2 additions & 10 deletions packages/oauth-server/src/client/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { z } from 'zod'

export const jwkRsaPubKeySchema = z.object({
kty: z.literal('RSA'),
e: z.string(),
n: z.string(),
kid: z.string().optional(),
alg: z.literal('RS256').optional(),
})

export const jwkShchema = z.union([jwkRsaPubKeySchema, jwkRsaPubKeySchema])
import { jwkShchema } from '../util/jwk'

// https://openid.net/specs/openid-connect-registration-1_0.html
export const clientMetadataSchema = z.object({
Expand All @@ -24,7 +16,7 @@ export const clientMetadataSchema = z.object({
token_endpoint_auth_method: z.enum(['none', 'private_key_jwt']).optional(),
token_endpoint_auth_signing_alg: z.enum(['RS256']).default('RS256'),
jwks_uri: z.string().url().optional(),
jwks: jwkShchema.optional(),
jwks: z.array(jwkShchema).optional(),
application_type: z.enum(['web', 'native']).default('web'),
subject_type: z.enum(['public']).default('public'), // 'pairwise' is not supported
id_token_signed_response_alg: z.enum(['RS256']).default('RS256'),
Expand Down
68 changes: 46 additions & 22 deletions packages/oauth-server/src/util/did-web.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
import { z } from 'zod'

const PREFIX = 'did:web:'
import { Fetch } from './fetch'
import {
fetchFailureHandler,
fetchResponseJsonHandler,
fetchResponseTypeHandler,
fetchResponseZodHandler,
fetchSuccessHandler,
} from './fetch-handlers'
import { Did, didDocumentSchema, didSchema } from './did'

export const DID_WEB = `did:web:`
export type DidWeb = `did:web:${string}`

export const didWebSchema = z
.custom<DidWeb>(
(data) =>
typeof data === 'string' &&
data.startsWith(PREFIX) &&
data.length > PREFIX.length &&
data.charAt(PREFIX.length) !== ':',
'Web DID must start with "did:web:"',
)
.refine((data) => {
try {
didWebToUrl(data)
return true
} catch {
return false
}
}, 'Invalid Web DID')

export function didWebToUrl(did: DidWeb): URL {
if (!did.startsWith(PREFIX)) {
export const didWebSchema = didSchema.refinement(isWebDid, {
code: z.ZodIssueCode.custom,
message: 'Invalid Web DID',
})

export function isWebDid(did: Did): did is DidWeb {
// Fast check
if (!did.startsWith(DID_WEB)) return false

try {
didWebToUrl(did)
return true
} catch {
return false
}
}

export function didWebToUrl(did: Did): URL {
if (!did.startsWith(DID_WEB)) {
throw new TypeError(`Not a Web DID`)
}
const suffix = did.slice(PREFIX.length)
const suffix = did.slice(DID_WEB.length)
if (!suffix || suffix.startsWith(':')) {
throw new TypeError(`Invalid Web DID`)
}
Expand All @@ -43,3 +52,18 @@ export function didDocumentUrl(didUrl: URL): URL {
export function didWebDocumentUrl(did: DidWeb): URL {
return didDocumentUrl(didWebToUrl(did))
}

export async function fetchDidDocument(didWeb: DidWeb, fetchFn: Fetch = fetch) {
const url = didWebDocumentUrl(didWeb)

const request = new Request(url, {
redirect: 'error',
headers: { accept: 'application/did+json,application/json' },
})

return fetchFn(request)
.then(fetchSuccessHandler(), fetchFailureHandler())
.then(fetchResponseTypeHandler(/^application\/(did\+)?json$/))
.then(fetchResponseJsonHandler())
.then(fetchResponseZodHandler(didDocumentSchema))
}
76 changes: 76 additions & 0 deletions packages/oauth-server/src/util/did.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { z } from 'zod'
import { jwkShchema } from './jwk'

export const DID = 'did:'

export type Did = `did:${string}:${string}`
export const didSchema = z
.string()
.max(2048, 'DID must not exceed 2048 characters')
.refinement(
(data): data is Did =>
/^did:[a-z0-9]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(data),
{
code: z.ZodIssueCode.custom,
message: 'Invalid DID',
},
)

/**
* RFC3968 compliant URI
*
* @see {@link https://www.rfc-editor.org/rfc/rfc3986}
*/
export const rfc3968UriSchema = z.string().refine((data) => {
try {
new URL(data)
return true
} catch {
return false
}
})

/**
* @node this schema might be too permissive
*/
export const relativeUriSchema = z.union([
rfc3968UriSchema,
z.string().regex(/^#.+$/),
])

export const verificationMethodSchema = z.object({
id: relativeUriSchema,
type: z.string().nonempty(),
controller: rfc3968UriSchema,
publicKeyJwk: jwkShchema.optional(),
publicKeyMultibase: z.string().optional(),
})

export const serviceSchema = z.object({
id: rfc3968UriSchema,
type: z.string().nonempty(),
serviceEndpoint: rfc3968UriSchema,
})

/**
* @note This schema is incomplete
*/
export const didDocumentSchema = z.object({
'@context': z
.array(z.string().url())
.nonempty()
.refine((data) => data.includes('https://www.w3.org/ns/did/v1')),
id: didSchema,
controller: z.union([didSchema, z.array(didSchema)]).optional(),
alsoKnownAs: z.array(rfc3968UriSchema).optional(),
service: z.array(serviceSchema).optional(),
verificationMethod: z.array(
z.union([verificationMethodSchema, relativeUriSchema]),
),
})

export type DidDocument = z.infer<typeof didDocumentSchema>

export function extractService(didDocument: DidDocument, type: string) {
return didDocument.service?.find((s) => s.type === type)
}
Loading

0 comments on commit e3d6949

Please sign in to comment.