Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .beads/backup/backup_state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"last_dolt_commit": "91m4cckvqk37siqj3hvpkicvgti131tj",
"last_event_id": 0,
"timestamp": "2026-03-04T18:38:55.591020539Z",
"counts": {
"issues": 0,
"events": 0,
"comments": 0,
"dependencies": 0,
"labels": 0,
"config": 12
}
}
Empty file added .beads/backup/comments.jsonl
Empty file.
12 changes: 12 additions & 0 deletions .beads/backup/config.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{"key":"auto_compact_enabled","value":"false"}
{"key":"compact_batch_size","value":"50"}
{"key":"compact_model","value":"claude-haiku-4-5-20251001"}
{"key":"compact_parallel_workers","value":"5"}
{"key":"compact_tier1_days","value":"30"}
{"key":"compact_tier1_dep_levels","value":"2"}
{"key":"compact_tier2_commits","value":"100"}
{"key":"compact_tier2_days","value":"90"}
{"key":"compact_tier2_dep_levels","value":"5"}
{"key":"compaction_enabled","value":"false"}
{"key":"schema_version","value":"6"}
{"key":"types.custom","value":"molecule,gate,convoy,merge-request,slot,agent,role,rig,message"}
Empty file.
Empty file added .beads/backup/events.jsonl
Empty file.
Empty file added .beads/backup/issues.jsonl
Empty file.
Empty file added .beads/backup/labels.jsonl
Empty file.
24 changes: 19 additions & 5 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ PDS_DPOP_SECRET=
# openssl ecparam -name secp256k1 -genkey -noout | openssl ec -text -noout 2>/dev/null | grep priv -A 3 | tail -n +2 | tr -d '[:space:]:'
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=

# Comma-separated OAuth client_id URLs trusted for CSS branding injection.
# Set identically for both pds-core and auth-service.
# PDS_OAUTH_TRUSTED_CLIENTS=

PDS_EMAIL_SMTP_URL=smtp://localhost:1025
PDS_EMAIL_FROM_ADDRESS=noreply@pds.example
PDS_BLOBSTORE_DISK_LOCATION=/data/blobs
Expand Down Expand Up @@ -108,6 +112,9 @@ EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify
SESSION_EXPIRES_IN=604800
SESSION_UPDATE_AGE=86400

# OTP_LENGTH=6
# OTP_FORMAT=numeric

# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
Expand Down
10 changes: 10 additions & 0 deletions packages/auth-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ PDS_ADMIN_PASSWORD=
# [shared] Set to 'development' for dev mode (disables secure cookies, etc.)
# NODE_ENV=development

# [shared] Comma-separated OAuth client_id URLs trusted for CSS branding injection.
# MUST match pds-core.
# PDS_OAUTH_TRUSTED_CLIENTS=

# -- Auth-service only --

# Subdomain for the auth UI (must be a subdomain of PDS_HOSTNAME)
Expand All @@ -60,6 +64,12 @@ EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify
SESSION_EXPIRES_IN=604800
SESSION_UPDATE_AGE=86400

# OTP code format
# OTP_LENGTH: 4-12, default 6
# OTP_FORMAT: 'numeric' (digits only) or 'alphanumeric' (uppercase, no ambiguous 0/O/1/I)
# OTP_LENGTH=6
# OTP_FORMAT=numeric

# Social login providers (both ID and SECRET must be set to enable)
# Google: https://console.cloud.google.com/
# GOOGLE_CLIENT_ID=
Expand Down
1 change: 1 addition & 0 deletions packages/auth-service/src/__tests__/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function makeMockContext(db: EpdsDb): AuthServiceContext {
fromName: 'Test PDS',
},
dbLocation: ':memory:',
trustedClients: [],
}

return {
Expand Down
2 changes: 2 additions & 0 deletions packages/auth-service/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface AuthServiceConfig {
fromName: string
}
dbLocation: string
/** OAuth client_id URLs trusted for CSS branding injection. */
trustedClients: string[]
}

const logger = createLogger('auth-service')
Expand Down
4 changes: 4 additions & 0 deletions packages/auth-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ async function main() {
fromName: process.env.SMTP_FROM_NAME || 'ePDS',
},
dbLocation: process.env.DB_LOCATION || './data/epds.sqlite',
trustedClients: (process.env.PDS_OAUTH_TRUSTED_CLIENTS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
}

await runBetterAuthMigrations(config.dbLocation, config.hostname)
Expand Down
117 changes: 11 additions & 106 deletions packages/auth-service/src/lib/client-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,13 @@
/**
* Resolves OAuth client metadata from client_id URLs.
* In ATProto, client_id is typically a URL pointing to a JSON metadata document.
* Caches results for 10 minutes to avoid repeated fetches.
* Re-export client metadata utilities from the shared package.
*
* Auth-service code imports from this local path for historical reasons;
* the implementation now lives in @certified-app/shared.
*/

export interface ClientMetadata {
client_name?: string
client_uri?: string
logo_uri?: string
tos_uri?: string
policy_uri?: string
email_template_uri?: string
email_subject_template?: string
brand_color?: string
background_color?: string
}

interface CacheEntry {
metadata: ClientMetadata
expiresAt: number
}

const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes
const FETCH_TIMEOUT_MS = 5000

const cache = new Map<string, CacheEntry>()

export async function resolveClientName(clientId: string): Promise<string> {
const metadata = await resolveClientMetadata(clientId)
return metadata.client_name || extractDomain(clientId) || 'an application'
}

export async function resolveClientMetadata(
clientId: string,
): Promise<ClientMetadata> {
// Only fetch if client_id looks like a URL
if (!clientId.startsWith('http://') && !clientId.startsWith('https://')) {
return { client_name: clientId }
}

// Check cache
const cached = cache.get(clientId)
if (cached && cached.expiresAt > Date.now()) {
return cached.metadata
}

try {
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort()
}, FETCH_TIMEOUT_MS)

const res = await fetch(clientId, {
signal: controller.signal,
headers: { Accept: 'application/json' },
})

clearTimeout(timeout)

if (!res.ok) {
return fallback(clientId)
}

const metadata = (await res.json()) as ClientMetadata

// Cache the result
cache.set(clientId, {
metadata,
expiresAt: Date.now() + CACHE_TTL_MS,
})

return metadata
} catch {
return fallback(clientId)
}
}

function fallback(clientId: string): ClientMetadata {
const name = extractDomain(clientId)
const metadata = { client_name: name || undefined }
// Cache failures briefly (1 minute) to avoid hammering
cache.set(clientId, {
metadata,
expiresAt: Date.now() + 60_000,
})
return metadata
}

function extractDomain(urlStr: string): string | null {
try {
const url = new URL(urlStr)
return url.hostname
} catch {
return null
}
}

// Cleanup expired cache entries periodically
setInterval(
() => {
const now = Date.now()
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key)
}
},
5 * 60 * 1000,
).unref()
export {
resolveClientMetadata,
resolveClientName,
escapeCss,
getClientCss,
} from '@certified-app/shared'
export type { ClientMetadata, ClientBranding } from '@certified-app/shared'
34 changes: 24 additions & 10 deletions packages/auth-service/src/routes/consent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Router, type Request, type Response } from 'express'
import type { AuthServiceContext } from '../context.js'
import { resolveClientName } from '../lib/client-metadata.js'
import { escapeHtml, signCallback } from '@certified-app/shared'
import { createLogger } from '@certified-app/shared'
import {
resolveClientMetadata,
resolveClientName,
getClientCss,
} from '../lib/client-metadata.js'
import { escapeHtml, signCallback, createLogger } from '@certified-app/shared'

const logger = createLogger('auth:consent')

Expand Down Expand Up @@ -41,9 +44,13 @@ export function createConsentRouter(ctx: AuthServiceContext): Router {
const email = req.query.email as string | undefined
const isNew = req.query.new === '1'
const clientId = flow.clientId ?? ''
const clientName = clientId
? await resolveClientName(clientId)
: 'the application'
const clientMeta = clientId ? await resolveClientMetadata(clientId) : {}
const clientName =
clientMeta.client_name ||
(clientId ? await resolveClientName(clientId) : 'the application')
const customCss = clientId
? getClientCss(clientId, clientMeta, ctx.config.trustedClients)
: null

res.type('html').send(
renderConsent({
Expand All @@ -53,6 +60,7 @@ export function createConsentRouter(ctx: AuthServiceContext): Router {
isNew,
clientId,
clientName,
customCss,
csrfToken: res.locals.csrfToken,
}),
)
Expand All @@ -70,9 +78,13 @@ export function createConsentRouter(ctx: AuthServiceContext): Router {
return
}

const clientName = clientId
? await resolveClientName(clientId)
: 'the application'
const clientMeta = clientId ? await resolveClientMetadata(clientId) : {}
const clientName =
clientMeta.client_name ||
(clientId ? await resolveClientName(clientId) : 'the application')
const customCss = clientId
? getClientCss(clientId, clientMeta, ctx.config.trustedClients)
: null

res.type('html').send(
renderConsent({
Expand All @@ -82,6 +94,7 @@ export function createConsentRouter(ctx: AuthServiceContext): Router {
isNew,
clientId: clientId || '',
clientName,
customCss,
csrfToken: res.locals.csrfToken,
}),
)
Expand Down Expand Up @@ -184,6 +197,7 @@ function renderConsent(opts: {
isNew: boolean
clientId: string
clientName: string
customCss: string | null
csrfToken: string
}): string {
const title = opts.isNew
Expand Down Expand Up @@ -223,7 +237,7 @@ function renderConsent(opts: {
.btn-approve:hover { background: #1a2a40; }
.btn-deny { background: #f0f0f0; color: #333; }
.btn-deny:hover { background: #e0e0e0; }
</style>
</style>${opts.customCss ? `\n <style>${opts.customCss}</style>` : ''}
</head>
<body>
<div class="container">
Expand Down
Loading