Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
45925d6
feat: add @ascorbic/atproto-oauth-provider package
claude Dec 30, 2025
c85890e
feat(pds): integrate OAuth 2.1 provider with Durable Object storage
claude Dec 30, 2025
36c8a85
fix(pds): use DO RPC proxy for OAuth storage serialization
claude Dec 30, 2025
f81267f
refactor(oauth-provider): improve security and library usage
claude Dec 30, 2025
02d58f1
feat(oauth-provider): add Content-Security-Policy for consent UI
claude Dec 30, 2025
2c42808
refactor(oauth-provider): use jose's base64url utilities
claude Dec 30, 2025
44247fe
refactor(oauth-provider): use map instead of switch for algorithm params
claude Dec 30, 2025
6c56015
refactor(oauth-provider): use const object with inference for algorit…
claude Dec 30, 2025
65938a5
fix(oauth-provider): use in check for proper null narrowing in getAlg…
claude Dec 30, 2025
6b99362
refactor(oauth-provider): use jose base64url directly, remove wrapper
claude Dec 30, 2025
046c7ac
fix(oauth-provider): resolve TypeScript diagnostics in dpop.ts
ascorbic Dec 30, 2025
9567d3e
refactor(oauth-provider): use types and schemas from @atproto/oauth-t…
ascorbic Dec 30, 2025
2d7629f
refactor(oauth-provider): use more types from @atproto/oauth-types
ascorbic Dec 30, 2025
2070d3c
fix(oauth-provider): add required AT Protocol OAuth metadata fields
ascorbic Dec 30, 2025
e460933
fix(oauth-provider): accept JSON body in PAR endpoint
ascorbic Dec 30, 2025
92c45d6
fix(oauth-provider): support URL-based client IDs in addition to DIDs
ascorbic Dec 30, 2025
fe72720
fix(oauth-provider): preserve OAuth params in form submission
ascorbic Dec 30, 2025
1894c29
feat(oauth-provider): support response_mode=fragment for auth redirects
ascorbic Dec 30, 2025
46c0248
fix(oauth-provider): accept JSON body in token endpoint
ascorbic Dec 30, 2025
fb9eef3
refactor(oauth-provider): extract parseRequestBody helper
ascorbic Dec 30, 2025
cd38e39
refactor(oauth-provider): parseRequestBody throws instead of returnin…
ascorbic Dec 30, 2025
c352a0e
feat: accept JSON body in token revocation endpoint
ascorbic Dec 30, 2025
badd661
fix(oauth-provider): include sub in token response
ascorbic Dec 30, 2025
628b824
feat(pds): support DPoP tokens in auth middleware and proxy
ascorbic Dec 30, 2025
b830d1c
refactor(oauth-provider): move test helpers out of production exports
ascorbic Dec 30, 2025
b33c5b8
fix(pds): fix TypeScript error in dotenv helper
ascorbic Dec 30, 2025
41fd909
fix: address PR review feedback
ascorbic Dec 30, 2025
be364bb
fix: address additional PR review feedback
ascorbic Dec 30, 2025
c658812
refactor: remove numbered comments from source files
ascorbic Dec 30, 2025
8e08bbc
fix: remove unused PKCE exports to fix knip
ascorbic Dec 30, 2025
7bb1ae4
docs: move oauth-provider plan to complete with updated status
claude Dec 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demos/pds/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"deploy": "vite build && wrangler deploy",
"deploy": "pnpm --filter @demo/pds... build && wrangler deploy",
"preview": "vite preview",
"pds": "pds",
"wrangler": "wrangler",
Expand Down
52 changes: 52 additions & 0 deletions packages/oauth-provider/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@ascorbic/atproto-oauth-provider",
"version": "0.0.0",
"description": "OAuth 2.1 Provider with AT Protocol extensions for Cloudflare Workers",
"type": "module",
"main": "dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"test": "vitest run",
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm"
},
"dependencies": {
"@atproto/oauth-types": "^0.5.2",
"@atproto/syntax": "^0.4.2",
"jose": "^6.1.3"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@cloudflare/workers-types": "^4.20251225.0",
"publint": "^0.3.16",
"tsdown": "^0.18.3",
"typescript": "^5.9.3",
"vitest": "^4.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ascorbic/atproto-worker.git",
"directory": "packages/oauth-provider"
},
"homepage": "https://github.com/ascorbic/atproto-worker",
"keywords": [
"atproto",
"bluesky",
"oauth",
"oauth2.1",
"dpop",
"pkce",
"cloudflare-workers"
],
"author": "Matt Kane",
"license": "MIT"
}
208 changes: 208 additions & 0 deletions packages/oauth-provider/src/client-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Client resolver for DID-based client discovery
* Resolves OAuth client metadata from DIDs for AT Protocol
*/

import { ensureValidDid } from "@atproto/syntax";
import {
oauthClientMetadataSchema,
type OAuthClientMetadata,
} from "@atproto/oauth-types";
import type { ClientMetadata, OAuthStorage } from "./storage.js";

export type { OAuthClientMetadata };

/**
* Client resolution error
*/
export class ClientResolutionError extends Error {
constructor(
message: string,
public readonly code: string
) {
super(message);
this.name = "ClientResolutionError";
}
}

/**
* Options for client resolution
*/
export interface ClientResolverOptions {
/** Storage for caching client metadata */
storage?: OAuthStorage;
/** Cache TTL in milliseconds (default: 1 hour) */
cacheTtl?: number;
/** Fetch function for making HTTP requests (for testing) */
fetch?: typeof globalThis.fetch;
}

/**
* Check if a string is a valid HTTPS URL
*/
function isHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:";
} catch {
return false;
}
}

/**
* Validate that a string is a valid DID using @atproto/syntax
*/
function isValidDid(value: string): boolean {
try {
ensureValidDid(value);
return true;
} catch {
return false;
}
}

/**
* Get the client metadata URL from a client ID
* Supports both URL-based and DID-based client IDs
*/
function getClientMetadataUrl(clientId: string): string | null {
// URL-based client ID: the URL itself is the metadata endpoint
if (isHttpsUrl(clientId)) {
return clientId;
}

// DID-based client ID: derive the metadata URL
if (clientId.startsWith("did:web:")) {
// did:web:example.com -> https://example.com/.well-known/oauth-client-metadata
// did:web:example.com:path -> https://example.com/path/.well-known/oauth-client-metadata
const parts = clientId.slice(8).split(":");
const host = parts[0]!.replace(/%3A/g, ":");
const path = parts.slice(1).join("/");
const baseUrl = `https://${host}${path ? "/" + path : ""}`;
return `${baseUrl}/.well-known/oauth-client-metadata`;
}

// Unsupported client ID format
return null;
}

/**
* Resolve client metadata from a DID
*/
export class ClientResolver {
private storage?: OAuthStorage;
private cacheTtl: number;
private fetchFn: typeof globalThis.fetch;

constructor(options: ClientResolverOptions = {}) {
this.storage = options.storage;
this.cacheTtl = options.cacheTtl ?? 60 * 60 * 1000; // 1 hour default
this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
}

/**
* Resolve client metadata from a client ID (URL or DID)
* @param clientId The client ID (HTTPS URL or DID)
* @returns The client metadata
* @throws ClientResolutionError if resolution fails
*/
async resolveClient(clientId: string): Promise<ClientMetadata> {
if (!isHttpsUrl(clientId) && !isValidDid(clientId)) {
throw new ClientResolutionError(
`Invalid client ID format: ${clientId}`,
"invalid_client"
);
}

if (this.storage) {
const cached = await this.storage.getClient(clientId);
if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) {
return cached;
}
}

const metadataUrl = getClientMetadataUrl(clientId);
if (!metadataUrl) {
throw new ClientResolutionError(
`Unsupported client ID format: ${clientId}`,
"invalid_client"
);
}

let response: Response;
try {
response = await this.fetchFn(metadataUrl, {
headers: {
Accept: "application/json",
},
});
} catch (e) {
throw new ClientResolutionError(
`Failed to fetch client metadata: ${e}`,
"invalid_client"
);
}

if (!response.ok) {
throw new ClientResolutionError(
`Client metadata fetch failed with status ${response.status}`,
"invalid_client"
);
}

let doc: OAuthClientMetadata;
try {
const json = await response.json();
doc = oauthClientMetadataSchema.parse(json);
} catch (e) {
throw new ClientResolutionError(
`Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`,
"invalid_client"
);
}

if (doc.client_id !== clientId) {
throw new ClientResolutionError(
`Client ID mismatch: expected ${clientId}, got ${doc.client_id}`,
"invalid_client"
);
}

const metadata: ClientMetadata = {
clientId: doc.client_id,
clientName: doc.client_name ?? clientId,
redirectUris: doc.redirect_uris,
logoUri: doc.logo_uri,
clientUri: doc.client_uri,
cachedAt: Date.now(),
};

if (this.storage) {
await this.storage.saveClient(clientId, metadata);
}

return metadata;
}

/**
* Validate that a redirect URI is allowed for a client
* @param clientId The client DID
* @param redirectUri The redirect URI to validate
* @returns true if the redirect URI is allowed
*/
async validateRedirectUri(clientId: string, redirectUri: string): Promise<boolean> {
try {
const metadata = await this.resolveClient(clientId);
return metadata.redirectUris.includes(redirectUri);
} catch {
return false;
}
}
}

/**
* Create a client resolver with optional caching
*/
export function createClientResolver(options: ClientResolverOptions = {}): ClientResolver {
return new ClientResolver(options);
}
Loading