-
Notifications
You must be signed in to change notification settings - Fork 600
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
84d9440
commit e3d6949
Showing
14 changed files
with
517 additions
and
129 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
), | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.