Skip to content

[HYPER-109] feat: custom CSS injection for trusted OAuth clients#9

Open
aspiers wants to merge 12 commits intomainfrom
css-injection-trusted-clients
Open

[HYPER-109] feat: custom CSS injection for trusted OAuth clients#9
aspiers wants to merge 12 commits intomainfrom
css-injection-trusted-clients

Conversation

@aspiers
Copy link
Contributor

@aspiers aspiers commented Mar 3, 2026

Summary

  • Extract resolveClientMetadata and ClientMetadata type into @certified-app/shared so both pds-core and auth-service can use them
  • Add branding.css support to ClientMetadata with escapeCss() (prevents </style> injection) and getClientCss() (trust-gated helper)
  • Auth-service: inject trusted client CSS into login and consent pages via <style> tag (CSP already allows 'unsafe-inline')
  • PDS-core: middleware intercepts /oauth/authorize responses — wraps res.setHeader/res.end to inject <style> tag and append SHA256 hash to CSP style-src directive
  • Demo app: add sample branding CSS (gradient buttons, CSS custom properties) to client metadata
  • Add PDS_OAUTH_TRUSTED_CLIENTS env var (comma-separated client_id URLs) to all env examples

How it works

  1. Trusted clients list their CSS in client-metadata.json under branding.css
  2. Both pds-core and auth-service read PDS_OAUTH_TRUSTED_CLIENTS (must be set identically)
  3. When a trusted client initiates OAuth, the CSS is fetched, escaped, and injected into authorization pages
  4. PDS side: CSP hash is computed and appended to style-src since the stock oauth-provider uses strict hash-based CSP
  5. Auth-service side: no CSP changes needed ('unsafe-inline' already allowed)

Test plan

  • All 173 existing tests pass
  • TypeScript builds cleanly for shared, auth-service, and pds-core
  • Manual: start with PDS_OAUTH_TRUSTED_CLIENTS set to demo's client_id, verify CSS appears on login/consent pages
  • Manual: remove from trusted list, verify CSS no longer injected
  • Manual: verify CSP header includes CSS hash on PDS authorization page

Ref: atproto-9fa

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Trusted OAuth clients can inject client-specific styling into consent, login and recovery pages.
    • OTP UI now adapts to configurable length/format, updating input attributes and user-facing descriptions.
  • Chores

    • .env examples expanded with commented variables for trusted clients and OTP configuration.
    • Tests and demo metadata updated to include trusted client handling.

aspiers and others added 5 commits March 3, 2026 21:53
… support

Move resolveClientMetadata, resolveClientName, and ClientMetadata type
from auth-service into @certified-app/shared so both pds-core and
auth-service can use them. Add branding.css to ClientMetadata interface,
escapeCss() for safe HTML embedding, and getClientCss() trust-gated helper.

Auth-service's client-metadata.ts becomes a re-export shim.

Ref: bd-20x.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ages

Add trustedClients config (from PDS_OAUTH_TRUSTED_CLIENTS env var) to
AuthServiceConfig. Login page and consent page now resolve full client
metadata, call getClientCss() for trusted clients, and inject a <style>
tag before </head>. Auth-service CSP already allows 'unsafe-inline' so
no header changes needed.

Ref: bd-20x.2, bd-20x.3, bd-20x.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For GET /oauth/authorize, intercept responses from the stock
@atproto/oauth-provider to inject custom CSS from trusted clients.
Wraps res.setHeader to append SHA256 hash to CSP style-src directive,
and wraps res.end to inject <style> tag before </head>.

Uses same Express stack insertion technique as AS metadata override.

Ref: bd-20x.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add gradient button styling and CSS custom properties to demonstrate
the custom CSS injection feature for trusted OAuth clients.

Ref: bd-20x.6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the comma-separated list of OAuth client_id URLs trusted for
CSS branding injection. Must be set identically in pds-core and
auth-service.

Ref: bd-20x.7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Mar 10, 2026 6:30am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds trusted-client configuration and per-client branding plus OTP configuration: new shared client-metadata and otp-config modules; shared re-exports; auth-service and pds-core resolve client metadata and inject/inline client CSS and OTP UI attributes into login/consent/recovery pages; env examples document PDS_OAUTH_TRUSTED_CLIENTS and OTP vars.

Changes

Cohort / File(s) Summary
Environment examples
\.env.example, packages/auth-service/.env.example, packages/pds-core/.env.example
Inserted PDS_OAUTH_TRUSTED_CLIENTS and added commented OTP config keys (OTP_LENGTH, OTP_FORMAT) into shared and package env example files.
Auth service config & tests
packages/auth-service/src/context.ts, packages/auth-service/src/index.ts, packages/auth-service/src/__tests__/consent.test.ts
Added trustedClients: string[] to AuthServiceConfig and populated it from PDS_OAUTH_TRUSTED_CLIENTS; updated tests to include the new field.
Shared: client metadata & OTP
packages/shared/src/client-metadata.ts, packages/shared/src/otp-config.ts, packages/shared/src/index.ts
New client-metadata (resolveClientMetadata, resolveClientName, escapeCss, getClientCss with caching/fetch/fallback) and otp-config (otpConfig, generateOtp, otpHtmlAttrs, otpDescriptionText, types); shared index re-exports these and adjusts public API.
Auth: metadata delegation & routing
packages/auth-service/src/lib/client-metadata.ts, packages/auth-service/src/routes/consent.ts, packages/auth-service/src/routes/login-page.ts, packages/auth-service/src/routes/recovery.ts
Replaced local metadata logic with shared re-exports; routes now resolve client metadata, compute customCss via getClientCss, thread customCss (and OTP attributes / requestUri) into renderers/templates, and inline CSS when present.
PDS core: CSS injection middleware
packages/pds-core/src/index.ts
Adds middleware on GET /oauth/authorize to resolve metadata for trusted clients, compute CSP sha256 for client CSS, inject inline <style> into HTML and augment CSP headers; imports shared helpers. (Duplication of injection block noted in file.)
Demo metadata
packages/demo/src/app/client-metadata.json/route.ts
Added branding.css field containing joined CSS string to demo client metadata response.
Misc & tooling
.beads/issues.jsonl, .beads/.gitignore, .beads/backup/*
Updated issue records, added daemon-error ignore pattern, and added backup/config JSONL entries; data/config additions only.

Sequence Diagram(s)

sequenceDiagram
    participant Client as OAuth Client
    participant PDS as PDS Core
    participant Shared as Shared Module
    participant Auth as Auth Service
    participant Browser as Browser/User

    Client->>PDS: GET /oauth/authorize?client_id=...
    PDS->>PDS: check client_id ∈ trustedClients
    alt trusted
        PDS->>Shared: resolveClientMetadata(client_id)
        Shared-->>PDS: ClientMetadata (branding.css)
        PDS->>Shared: getClientCss(client_id, metadata, trustedClients)
        Shared-->>PDS: Escaped CSS
        PDS->>PDS: compute SHA-256(CSS) and augment CSP
        PDS->>PDS: inject <style> into HTML head
    end
    PDS-->>Browser: HTML + CSP headers
    Browser->>Auth: open /login or act on consent/recovery
    Auth->>Shared: resolveClientMetadata(client_id)
    Shared-->>Auth: ClientMetadata
    Auth->>Shared: getClientCss(client_id, metadata, trustedClients)
    Shared-->>Auth: Escaped CSS
    Auth-->>Browser: Branded HTML (login/consent/recovery) with OTP attrs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 A hop of CSS in moonlit night,
Trusted clients glowing bright,
Metadata fetched, cached with care,
OTPs shaped and in the air,
A rabbit cheers: styles take flight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being added: custom CSS injection for trusted OAuth clients, which is the primary focus of all the changes across multiple packages.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch css-injection-trusted-clients

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@aspiers aspiers force-pushed the css-injection-trusted-clients branch from 1f498b1 to 97877a3 Compare March 3, 2026 21:58
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/shared/src/client-metadata.ts (1)

118-127: Consider case-insensitive comparison for trusted client IDs.

The trustedClients.includes(clientId) check is case-sensitive. While OAuth client IDs are typically URLs and should be compared exactly, ensure that the configuration in PDS_OAUTH_TRUSTED_CLIENTS matches the exact client_id values used in requests (including trailing slashes, protocol, etc.).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/client-metadata.ts` around lines 118 - 127, The trusted
client check in getClientCss is currently case-sensitive; update the logic to
perform a case-insensitive comparison (e.g., compare clientId.toLowerCase()
against trustedClients entries normalized with .map(s => s.toLowerCase())) and
consider normalizing trivial URL differences (trim and optionally remove a
trailing slash) before comparing so trusted client matching is robust; modify
getClientCss to use the normalized comparison when evaluating
trustedClients.includes(...).
packages/pds-core/src/index.ts (1)

348-401: Async middleware without explicit error handling for next().

The middleware is async but Express doesn't natively handle promise rejections in middleware. While there's a try-catch around the main logic that calls next() on error, if resolveClientMetadata throws after next() is somehow not reached, Express won't catch it. The current implementation looks correct, but consider wrapping the entire function body or using an async middleware wrapper for robustness.

This is a minor concern given the existing try-catch structure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pds-core/src/index.ts` around lines 348 - 401, The
cssInjectionMiddleware is declared async but currently swallows errors by
calling next() in the catch; change error handling to forward exceptions to
Express by calling next(err) inside the catch so unhandled rejections reach the
error handler, or wrap the middleware with an async wrapper (e.g.,
express-async-handler) and remove the try/catch; specifically update
cssInjectionMiddleware (and where resolveClientMetadata is awaited) to call
next(err) instead of next() on failure, or export/replace the async function
with a wrapper that catches promise rejections and forwards them to next.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 228-229: The imports otpHtmlAttrs and otpDescriptionText
referenced by login-page.ts are missing because packages/shared lacks
otp-config.ts and index.ts exports ./otp-config.js point to a non-existent
module; fix by either (A) creating packages/shared/src/otp-config.ts that
exports otpHtmlAttrs (returns the OTP input HTML attributes such as
maxlength="8" and pattern="[0-9]{8}") and otpDescriptionText (returns the
8-digit OTP description string), and update the package/shared index.ts to
export these symbols, or (B) change the import in login-page.ts to the correct
existing module that provides these utilities; ensure the exported function
names otpHtmlAttrs and otpDescriptionText are used exactly as in login-page.ts.

In `@packages/pds-core/src/index.ts`:
- Around line 383-394: The overridden response end uses untyped any and a loose
rest param; update types to remove ESLint errors and match Node signatures by
typing origEnd as typeof res.end, and change the override to res.end = (chunk?:
string | Buffer | null, encoding?: BufferEncoding | ((err?: Error) => void) |
undefined, callback?: (() => void) | undefined) => { ... } (handle encoding
being a callback), check Buffer.isBuffer and typeof chunk === 'string' as
before, and call origEnd(chunk as any, encoding as any, callback as any) to
satisfy typing while preserving behavior; reference origEnd, res.end, and
styleTag when editing.

In `@packages/shared/src/index.ts`:
- Around line 27-33: The re-export block in index.ts references a non-existent
module './otp-config.js' causing build failures; either add a new module that
exports otpConfig, generateOtp, otpHtmlAttrs, otpDescriptionText and the types
OtpFormat and OtpConfig (implementing their expected signatures), or remove
these re-exports from the file if OTP functionality isn’t part of this
change—update the import paths in any callers to point to the new module name if
you create it, and ensure the exported symbol names (otpConfig, generateOtp,
otpHtmlAttrs, otpDescriptionText, OtpFormat, OtpConfig) exactly match what's
declared.

---

Nitpick comments:
In `@packages/pds-core/src/index.ts`:
- Around line 348-401: The cssInjectionMiddleware is declared async but
currently swallows errors by calling next() in the catch; change error handling
to forward exceptions to Express by calling next(err) inside the catch so
unhandled rejections reach the error handler, or wrap the middleware with an
async wrapper (e.g., express-async-handler) and remove the try/catch;
specifically update cssInjectionMiddleware (and where resolveClientMetadata is
awaited) to call next(err) instead of next() on failure, or export/replace the
async function with a wrapper that catches promise rejections and forwards them
to next.

In `@packages/shared/src/client-metadata.ts`:
- Around line 118-127: The trusted client check in getClientCss is currently
case-sensitive; update the logic to perform a case-insensitive comparison (e.g.,
compare clientId.toLowerCase() against trustedClients entries normalized with
.map(s => s.toLowerCase())) and consider normalizing trivial URL differences
(trim and optionally remove a trailing slash) before comparing so trusted client
matching is robust; modify getClientCss to use the normalized comparison when
evaluating trustedClients.includes(...).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9351243 and 1f498b1.

📒 Files selected for processing (14)
  • .beads/issues.jsonl
  • .env.example
  • packages/auth-service/.env.example
  • packages/auth-service/src/__tests__/consent.test.ts
  • packages/auth-service/src/context.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/lib/client-metadata.ts
  • packages/auth-service/src/routes/consent.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/demo/src/app/client-metadata.json/route.ts
  • packages/pds-core/.env.example
  • packages/pds-core/src/index.ts
  • packages/shared/src/client-metadata.ts
  • packages/shared/src/index.ts

Comment on lines +383 to +394
const origEnd = res.end.bind(res)
res.end = (chunk: any, ...args: any[]) => {
if (typeof chunk === 'string' && chunk.includes('</head>')) {
chunk = chunk.replace('</head>', `${styleTag}</head>`)
} else if (Buffer.isBuffer(chunk)) {
const str = chunk.toString('utf-8')
if (str.includes('</head>')) {
chunk = str.replace('</head>', `${styleTag}</head>`)
}
}
return origEnd(chunk, ...args)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add type annotations to fix ESLint errors and improve type safety.

The any types on line 384 trigger ESLint errors. While this is internal Express plumbing, adding minimal type annotations satisfies the linter and documents intent.

Also, the res.end signature in Node.js can accept (chunk, encoding, callback). The current spread may work, but being explicit is safer.

Proposed fix
         // Wrap res.end to inject the <style> tag before </head>
         const origEnd = res.end.bind(res)
-        res.end = (chunk: any, ...args: any[]) => {
+        res.end = (chunk?: Buffer | string, encoding?: BufferEncoding | (() => void), cb?: () => void) => {
           if (typeof chunk === 'string' && chunk.includes('</head>')) {
             chunk = chunk.replace('</head>', `${styleTag}</head>`)
           } else if (Buffer.isBuffer(chunk)) {
             const str = chunk.toString('utf-8')
             if (str.includes('</head>')) {
               chunk = str.replace('</head>', `${styleTag}</head>`)
             }
           }
-          return origEnd(chunk, ...args)
+          return origEnd(chunk, encoding as BufferEncoding, cb)
         }
🧰 Tools
🪛 ESLint

[error] 384-384: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 384-384: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🪛 GitHub Check: lint

[failure] 384-384:
Unexpected any. Specify a different type


[failure] 384-384:
Unexpected any. Specify a different type

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pds-core/src/index.ts` around lines 383 - 394, The overridden
response end uses untyped any and a loose rest param; update types to remove
ESLint errors and match Node signatures by typing origEnd as typeof res.end, and
change the override to res.end = (chunk?: string | Buffer | null, encoding?:
BufferEncoding | ((err?: Error) => void) | undefined, callback?: (() => void) |
undefined) => { ... } (handle encoding being a callback), check Buffer.isBuffer
and typeof chunk === 'string' as before, and call origEnd(chunk as any, encoding
as any, callback as any) to satisfy typing while preserving behavior; reference
origEnd, res.end, and styleTag when editing.

Comment on lines +27 to +33
export {
otpConfig,
generateOtp,
otpHtmlAttrs,
otpDescriptionText,
} from './otp-config.js'
export type { OtpFormat, OtpConfig } from './otp-config.js'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if otp-config.ts/js exists in the shared package
echo "=== Searching for otp-config files ==="
fd -t f 'otp-config' packages/shared/

echo -e "\n=== Listing all .ts files in packages/shared/src ==="
ls -la packages/shared/src/*.ts 2>/dev/null || echo "No .ts files found"

echo -e "\n=== Checking if client-metadata.ts exists ==="
fd -t f 'client-metadata' packages/shared/

echo -e "\n=== Checking the index.ts file at lines 25-35 ==="
sed -n '25,35p' packages/shared/src/index.ts

Repository: hypercerts-org/ePDS

Length of output: 1140


Build failure: Missing ./otp-config.js module.

The exports on lines 27-33 reference ./otp-config.js, which does not exist in packages/shared/src/. No otp-config.ts or compiled otp-config.js file is present. This will cause TypeScript compilation to fail for the shared package and any dependents. Either the file needs to be created with the exported functions (otpConfig, generateOtp, otpHtmlAttrs, otpDescriptionText) and types (OtpFormat, OtpConfig), or the exports must be removed if this module is not part of this PR.

🧰 Tools
🪛 GitHub Actions: CI

[error] 32-32: Cannot find module './otp-config.js' or its corresponding type declarations. (TS2307) During 'pnpm --recursive build' step.

🪛 GitHub Check: typecheck

[failure] 33-33:
Cannot find module './otp-config.js' or its corresponding type declarations.


[failure] 32-32:
Cannot find module './otp-config.js' or its corresponding type declarations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/index.ts` around lines 27 - 33, The re-export block in
index.ts references a non-existent module './otp-config.js' causing build
failures; either add a new module that exports otpConfig, generateOtp,
otpHtmlAttrs, otpDescriptionText and the types OtpFormat and OtpConfig
(implementing their expected signatures), or remove these re-exports from the
file if OTP functionality isn’t part of this change—update the import paths in
any callers to point to the new module name if you create it, and ensure the
exported symbol names (otpConfig, generateOtp, otpHtmlAttrs, otpDescriptionText,
OtpFormat, OtpConfig) exactly match what's declared.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.beads/issues.jsonl:
- Around line 19-21: The issue is that the atproto-9fa parent and its child
tasks (atproto-9fa.1, .2, etc.) have inconsistent statuses and inverted
dependency directions; fix by updating the JSON entries so the parent
atproto-9fa remains open until all child tasks are closed (set "status":"open"
for the parent if children are still open) and correct the dependency edges:
make the shared extraction task atproto-9fa.1 an upstream dependency (other
tasks should have depends_on entries pointing to "atproto-9fa.1" and/or the
parent "atproto-9fa") instead of using inverted "blocks" relationships, and
ensure each dependency object uses the proper "depends_on_id" value and "type"
(use "depends_on" for normal dependency rather than "blocks" where appropriate);
update the "dependencies" arrays for atproto-9fa, atproto-9fa.1, and
atproto-9fa.2 to reflect the correct upstream/downstream ordering and set
statuses consistently.
- Line 17: The default OTP length should remain 8 (not 6): update the shared OTP
config module to read OTP_LENGTH (default 8) and OTP_FORMAT (default "numeric"),
ensure the exported config and generateOTP logic use that default, update any
wiring in better-auth.ts (otpLength + generateOTP callback) and .env.example to
reflect OTP_LENGTH=8, and adjust any tests/HTML defaults (login-page.ts,
recovery.ts, account-login.ts) that assumed 6 so they now use the configured
default of 8.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1f498b1 and 97877a3.

📒 Files selected for processing (1)
  • .beads/issues.jsonl

@aspiers aspiers changed the title feat: custom CSS injection for trusted OAuth clients [HYPER-109] feat: custom CSS injection for trusted OAuth clients Mar 3, 2026
aspiers and others added 2 commits March 3, 2026 23:31
Replace unused CSS custom properties with a full dark theme override
while keeping the gradient button styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This file was created on a previous branch but never committed,
causing CI typecheck failures since shared/src/index.ts re-exports it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add eslint-disable for res.end wrapper's any types (complex Node
overloads). Run prettier on consent.ts and pds-core/index.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
.1 (shared extraction) now blocks .2 and .5 (not the reverse).
.2 (auth config) now blocks .3 and .4 (not the reverse).
Also closes .1 and .2 which were left open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/auth-service/src/routes/recovery.ts (1)

287-302: ⚠️ Potential issue | 🟠 Major

Recovery still ignores the shared OTP format helpers.

This template still hard-codes “8-digit code” and [0-9]{8}, while login-page.ts now renders from otpHtmlAttrs() / otpDescriptionText(). Any non-default OTP_LENGTH or OTP_FORMAT will make /auth/recover reject valid codes client-side.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/routes/recovery.ts` around lines 287 - 302, The
recovery template hard-codes "8-digit code" and the pattern/maxlength on the
code input, causing client-side rejection when OTP_LENGTH or OTP_FORMAT are
non-default; update the HTML to use the shared helpers instead of literals: call
otpDescriptionText(...) to render the description sentence (replace the
hard-coded "8-digit code" and maskedEmail line) and use otpHtmlAttrs(...) to
produce the input attributes for the code field (replace maxlength, pattern,
inputmode, autocomplete, placeholder) so /auth/recover/verify accepts the same
OTP formats as login-page.ts; ensure the same imports/usage as in login-page.ts
and pass the same opts (or relevant values) to these helper functions and remove
the hard-coded values.
packages/auth-service/src/routes/login-page.ts (1)

128-139: ⚠️ Potential issue | 🟠 Major

Don't make /oauth/authorize depend on metadata availability.

resolveClientMetadata(clientId) is awaited before any fallback, so a transient metadata fetch error aborts the page and resolveClientName() never runs. Since branding is optional, this should fall back to a generic client name/unstyled UI instead of failing the auth flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/routes/login-page.ts` around lines 128 - 139, The
code currently awaits resolveClientMetadata(clientId) and will abort the flow on
transient metadata errors; instead wrap the resolveClientMetadata(clientId) call
in a try/catch and on error set clientMeta = {} (or fallback object) so
execution continues; then compute clientName the same way
(clientMeta.client_name ?? (clientId ? await resolveClientName(clientId) : 'an
application')) so resolveClientName still runs as a fallback, and call
getClientCss(clientId, clientMeta, ctx.config.trustedClients) with the fallback
clientMeta (or null if you prefer no CSS) so CSS and branding remain optional
and metadata failures no longer block /oauth/authorize.
♻️ Duplicate comments (1)
packages/auth-service/src/routes/consent.ts (1)

47-53: ⚠️ Potential issue | 🟠 Major

Handle client-metadata lookup failures here too.

Same failure mode as the authorize page: both branches await resolveClientMetadata(clientId) before fallback logic, so a transient metadata error turns the consent screen into a hard failure instead of generic branding.

Also applies to: 81-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/routes/consent.ts` around lines 47 - 53, The code
calls resolveClientMetadata(clientId) directly and lets its errors bubble, which
makes the consent page fail on transient metadata lookup errors; update the
logic around resolveClientMetadata(clientId) (used when computing clientMeta,
clientName, and customCss and similarly in the later branch at the other
occurrence) to catch and handle errors by treating failures as an empty
clientMeta (e.g., clientMeta = {}) and fallback to the existing
resolveClientName(clientId) or 'the application' and null CSS; also log or trace
the metadata error for observability but do not let it block rendering the
consent page.
🧹 Nitpick comments (1)
.beads/backup/backup_state.json (1)

2-11: Keep generated backup state out of the PR.

This snapshot looks tool-generated and mutable (last_dolt_commit, timestamp, counts). If it is not required at runtime, tracking it will add noisy diffs and conflict churn on unrelated changes. Prefer ignoring .beads/backup/backup_state.json or generating it outside the repo.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.beads/backup/backup_state.json around lines 2 - 11, The backup_state.json
snapshot is generated/mutable and should not be tracked; remove it from source
control and stop future commits by adding an ignore rule. Remove the tracked
file from the repository index (e.g., untrack it with git rm --cached), add an
appropriate .gitignore entry for backup_state.json (or the .beads/backup folder)
so it isn’t committed again, commit the removal, and ensure whatever process
currently produces last_dolt_commit/timestamp/counts regenerates the file
outside the repo or writes it to a build/temp directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/routes/recovery.ts`:
- Around line 319-326: resolveCustomCss() currently always calls
resolveClientMetadata(clientId) even for untrusted client IDs; change it to
trust-gate first and avoid the outbound fetch for untrusted IDs. Specifically,
in resolveCustomCss check whether clientId is in trustedClients (or otherwise
considered trusted) and return null immediately if not trusted, and only call
resolveClientMetadata(clientId) and then getClientCss(clientId, meta,
trustedClients) when the client is trusted.

In `@packages/pds-core/src/index.ts`:
- Around line 385-398: The override of res.end (origEnd/res.end) mutates the
response body by injecting styleTag after the upstream handler may have already
set Content-Length or ETag, causing mismatched headers; modify the res.end
wrapper so that when you detect and rewrite the HTML (when chunk/string/Buffer
includes '</head>') you also clear stale entity headers by removing
'Content-Length' and 'ETag' (e.g., call res.removeHeader('content-length') and
res.removeHeader('etag')) before calling origEnd; ensure this logic is applied
in the same branch where chunk is replaced so origEnd is invoked with the
modified chunk and without stale headers.

In `@packages/shared/src/otp-config.ts`:
- Around line 27-29: The default OTP length currently falls back to 6 when
OTP_LENGTH is unset; change the fallback to 8 so existing auth and recovery UX
remain consistent by updating the rawLength/length logic in otp-config.ts to use
8 as the default (keep the same parseInt and validation: isNaN(length) || length
< 4 || length > 12). Ensure references to OTP_LENGTH still parse/process the env
var but if undefined the computed length variable defaults to 8.

---

Outside diff comments:
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 128-139: The code currently awaits resolveClientMetadata(clientId)
and will abort the flow on transient metadata errors; instead wrap the
resolveClientMetadata(clientId) call in a try/catch and on error set clientMeta
= {} (or fallback object) so execution continues; then compute clientName the
same way (clientMeta.client_name ?? (clientId ? await
resolveClientName(clientId) : 'an application')) so resolveClientName still runs
as a fallback, and call getClientCss(clientId, clientMeta,
ctx.config.trustedClients) with the fallback clientMeta (or null if you prefer
no CSS) so CSS and branding remain optional and metadata failures no longer
block /oauth/authorize.

In `@packages/auth-service/src/routes/recovery.ts`:
- Around line 287-302: The recovery template hard-codes "8-digit code" and the
pattern/maxlength on the code input, causing client-side rejection when
OTP_LENGTH or OTP_FORMAT are non-default; update the HTML to use the shared
helpers instead of literals: call otpDescriptionText(...) to render the
description sentence (replace the hard-coded "8-digit code" and maskedEmail
line) and use otpHtmlAttrs(...) to produce the input attributes for the code
field (replace maxlength, pattern, inputmode, autocomplete, placeholder) so
/auth/recover/verify accepts the same OTP formats as login-page.ts; ensure the
same imports/usage as in login-page.ts and pass the same opts (or relevant
values) to these helper functions and remove the hard-coded values.

---

Duplicate comments:
In `@packages/auth-service/src/routes/consent.ts`:
- Around line 47-53: The code calls resolveClientMetadata(clientId) directly and
lets its errors bubble, which makes the consent page fail on transient metadata
lookup errors; update the logic around resolveClientMetadata(clientId) (used
when computing clientMeta, clientName, and customCss and similarly in the later
branch at the other occurrence) to catch and handle errors by treating failures
as an empty clientMeta (e.g., clientMeta = {}) and fallback to the existing
resolveClientName(clientId) or 'the application' and null CSS; also log or trace
the metadata error for observability but do not let it block rendering the
consent page.

---

Nitpick comments:
In @.beads/backup/backup_state.json:
- Around line 2-11: The backup_state.json snapshot is generated/mutable and
should not be tracked; remove it from source control and stop future commits by
adding an ignore rule. Remove the tracked file from the repository index (e.g.,
untrack it with git rm --cached), add an appropriate .gitignore entry for
backup_state.json (or the .beads/backup folder) so it isn’t committed again,
commit the removal, and ensure whatever process currently produces
last_dolt_commit/timestamp/counts regenerates the file outside the repo or
writes it to a build/temp directory.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 17d33f23-9074-4e83-983d-660ae6397ae2

📥 Commits

Reviewing files that changed from the base of the PR and between 97877a3 and c22d690.

📒 Files selected for processing (15)
  • .beads/.gitignore
  • .beads/backup/backup_state.json
  • .beads/backup/comments.jsonl
  • .beads/backup/config.jsonl
  • .beads/backup/dependencies.jsonl
  • .beads/backup/events.jsonl
  • .beads/backup/issues.jsonl
  • .beads/backup/labels.jsonl
  • .beads/issues.jsonl
  • packages/auth-service/src/routes/consent.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/recovery.ts
  • packages/demo/src/app/client-metadata.json/route.ts
  • packages/pds-core/src/index.ts
  • packages/shared/src/otp-config.ts
✅ Files skipped from review due to trivial changes (1)
  • .beads/backup/config.jsonl

Comment on lines +385 to +398
// Wrap res.end to inject the <style> tag before </head>
const origEnd = res.end.bind(res)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- wrapping Node http.ServerResponse.end() which has complex overloads
res.end = (chunk: any, ...args: any[]) => {
if (typeof chunk === 'string' && chunk.includes('</head>')) {
chunk = chunk.replace('</head>', `${styleTag}</head>`)
} else if (Buffer.isBuffer(chunk)) {
const str = chunk.toString('utf-8')
if (str.includes('</head>')) {
chunk = str.replace('</head>', `${styleTag}</head>`)
}
}
return origEnd(chunk, ...args)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear stale entity headers when you rewrite the HTML.

This mutates the body after the upstream handler has likely already set Content-Length/ETag. For trusted clients, that can produce a mismatched response and broken authorize pages.

Suggested fix
         // Wrap res.end to inject the <style> tag before </head>
         const origEnd = res.end.bind(res)
         // eslint-disable-next-line `@typescript-eslint/no-explicit-any` -- wrapping Node http.ServerResponse.end() which has complex overloads
         res.end = (chunk: any, ...args: any[]) => {
+          let mutated = false
           if (typeof chunk === 'string' && chunk.includes('</head>')) {
             chunk = chunk.replace('</head>', `${styleTag}</head>`)
+            mutated = true
           } else if (Buffer.isBuffer(chunk)) {
             const str = chunk.toString('utf-8')
             if (str.includes('</head>')) {
               chunk = str.replace('</head>', `${styleTag}</head>`)
+              mutated = true
             }
           }
+          if (mutated) {
+            res.removeHeader('Content-Length')
+            res.removeHeader('ETag')
+          }
           return origEnd(chunk, ...args)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pds-core/src/index.ts` around lines 385 - 398, The override of
res.end (origEnd/res.end) mutates the response body by injecting styleTag after
the upstream handler may have already set Content-Length or ETag, causing
mismatched headers; modify the res.end wrapper so that when you detect and
rewrite the HTML (when chunk/string/Buffer includes '</head>') you also clear
stale entity headers by removing 'Content-Length' and 'ETag' (e.g., call
res.removeHeader('content-length') and res.removeHeader('etag')) before calling
origEnd; ensure this logic is applied in the same branch where chunk is replaced
so origEnd is invoked with the modified chunk and without stale headers.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/auth-service/src/routes/recovery.ts (1)

325-333: ⚠️ Potential issue | 🟠 Major

Trust-gate before resolving client metadata.

Line 331 still resolves metadata before the allowlist check in getClientCss(), so any public recovery request with an arbitrary client_id can trigger an outbound lookup. Short-circuit on trustedClients first and only fetch metadata for trusted IDs.

Suggested fix
 async function resolveCustomCss(
   clientId: string | undefined,
   trustedClients: string[],
 ): Promise<string | null> {
-  if (!clientId) return null
+  if (!clientId || !trustedClients.includes(clientId)) return null
   try {
     const meta: ClientMetadata = await resolveClientMetadata(clientId)
     return getClientCss(clientId, meta, trustedClients)
   } catch {
     return null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/routes/recovery.ts` around lines 325 - 333,
resolveCustomCss currently fetches client metadata before checking the trusted
allowlist, allowing untrusted public requests to trigger outbound lookups;
modify resolveCustomCss to first short-circuit by returning null if clientId is
not in trustedClients (e.g., if (!clientId ||
!trustedClients.includes(clientId)) return null) and only call
resolveClientMetadata and getClientCss for trusted IDs so metadata is resolved
only for allowlisted clients.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 141-149: The logger.debug call is exposing the full trusted-client
allowlist (ctx.config.trustedClients); update the log to omit the full array and
instead log only isTrusted and a trustedCount (e.g.,
ctx.config.trustedClients.length) alongside clientId and other safe fields; keep
the isTrusted computation (ctx.config.trustedClients.includes(clientId ?? ''))
and retain customCss/customCss length logging but remove trustedClients from the
logged object to avoid writing the allowlist to logs (modify the logger.debug
invocation near logger.debug and clientId).
- Around line 368-369: The recovery-link anchor interpolates user-controlled
opts.requestUri and opts.clientId using encodeURIComponent but does not apply
HTML escaping; update the template to pass those encoded values through
escapeHtml() from `@certified-app/shared` before embedding them in the href
attribute. Locate the fragment with id "recovery-link" (in the login-page
rendering/template) and replace encodeURIComponent(opts.requestUri) and
encodeURIComponent(opts.clientId) with
escapeHtml(encodeURIComponent(opts.requestUri)) and
escapeHtml(encodeURIComponent(opts.clientId)) respectively so the params are
both URL-encoded and HTML-escaped.

---

Duplicate comments:
In `@packages/auth-service/src/routes/recovery.ts`:
- Around line 325-333: resolveCustomCss currently fetches client metadata before
checking the trusted allowlist, allowing untrusted public requests to trigger
outbound lookups; modify resolveCustomCss to first short-circuit by returning
null if clientId is not in trustedClients (e.g., if (!clientId ||
!trustedClients.includes(clientId)) return null) and only call
resolveClientMetadata and getClientCss for trusted IDs so metadata is resolved
only for allowlisted clients.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 431ccba0-8f89-4516-a8f2-c41944896bab

📥 Commits

Reviewing files that changed from the base of the PR and between c22d690 and 0356474.

📒 Files selected for processing (11)
  • .beads/.gitignore
  • .beads/backup/backup_state.json
  • .beads/backup/comments.jsonl
  • .beads/backup/config.jsonl
  • .beads/backup/dependencies.jsonl
  • .beads/backup/events.jsonl
  • .beads/backup/issues.jsonl
  • .beads/backup/labels.jsonl
  • .beads/issues.jsonl
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/recovery.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • .beads/.gitignore
  • .beads/backup/backup_state.json
  • .beads/backup/config.jsonl

Instead of solely relying on the query params for the client-id we add a fallback to also use the request-uri to fetch the actual row from the oauth-flow. From which we can get the client id and then fetch the custom css.
@Kzoeps
Copy link
Contributor

Kzoeps commented Mar 10, 2026

@aspiers updated the logic to fetch the custom css a bit (refer to this commit)

we now pass the actual request_uri in the query params like how bsky oauth does it. previously it was passing a placeholder. We then use that request_uri to fetch the client id and subsequently the client-metadata if the query params is missing the client-id.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/shared/src/otp-config.ts (1)

25-42: ⚠️ Potential issue | 🟠 Major

Keep the auth-service OTP contract fixed at 8 numeric digits.

resolveOtpConfig() now permits 4–12 characters and alphanumeric, and generateOtp() applies that config globally. Since auth-service consumes this shared module for OTP behavior/UI, a deploy-time env change can silently move login and recovery flows away from the required 8-digit numeric contract. If other packages need configurable OTPs, keep that in a separate helper instead of the path used by auth-service.

Suggested change
 /** Read and validate OTP config from environment variables. */
 function resolveOtpConfig(): OtpConfig {
-  const rawLength = process.env.OTP_LENGTH
-  const length = rawLength ? parseInt(rawLength, 10) : 8
-  if (isNaN(length) || length < 4 || length > 12) {
-    throw new Error(
-      `OTP_LENGTH must be an integer between 4 and 12, got: ${rawLength}`,
-    )
-  }
-
-  const rawFormat = process.env.OTP_FORMAT ?? 'numeric'
-  if (rawFormat !== 'numeric' && rawFormat !== 'alphanumeric') {
-    throw new Error(
-      `OTP_FORMAT must be 'numeric' or 'alphanumeric', got: ${rawFormat}`,
-    )
-  }
-
-  return { length, format: rawFormat }
+  return { length: 8, format: 'numeric' }
 }

As per coding guidelines, "OTP codes must be 8-digit, single-use, managed by better-auth".

Also applies to: 46-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/otp-config.ts` around lines 25 - 42, The shared
resolveOtpConfig currently allows variable lengths and formats which can change
auth-service behavior at deploy; fix it so resolveOtpConfig() always returns {
length: 8, format: 'numeric' } (remove parsing/validation of OTP_LENGTH and
OTP_FORMAT) so auth-service remains fixed to 8-digit numeric OTPs, and if
configurable OTPs are needed create a separate helper (e.g.,
resolveConfigForFlexibleOtp or generateConfigurableOtp) for other packages
instead of altering resolveOtpConfig; ensure generateOtp() continues to consume
resolveOtpConfig() unchanged so it emits 8-digit numeric codes for auth-service.
🧹 Nitpick comments (1)
packages/auth-service/src/routes/login-page.ts (1)

23-42: Reorder this import block to match the repo convention.

node: built-ins should come first, then external/internal package imports, and local ../... imports last. The new @certified-app/shared import currently sits after local imports.

Suggested change
-import { Router, type Request, type Response } from 'express'
 import { randomBytes } from 'node:crypto'
+import { Router, type Request, type Response } from 'express'
+import {
+  escapeHtml,
+  createLogger,
+  otpHtmlAttrs,
+  otpDescriptionText,
+} from '@certified-app/shared'
 import type { AuthServiceContext } from '../context.js'
 import {
   resolveClientMetadata,
   resolveClientName,
   getClientCss,
   type ClientMetadata,
 } from '../lib/client-metadata.js'
-import {
-  escapeHtml,
-  createLogger,
-  otpHtmlAttrs,
-  otpDescriptionText,
-} from '@certified-app/shared'
 import { socialProviders } from '../better-auth.js'
 import {
   resolveLoginHint,
   fetchParLoginHint,

As per coding guidelines, "Order imports as: Node built-ins, external packages, internal workspace packages, local relative imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/routes/login-page.ts` around lines 23 - 42, The
import block is misordered: bring Node built-ins first (keep "import {
randomBytes } from 'node:crypto'"), then external packages (move "import {
Router, type Request, type Response } from 'express'" and "import { escapeHtml,
createLogger, otpHtmlAttrs, otpDescriptionText } from '@certified-app/shared'"
and "import { socialProviders } from '../better-auth.js'" into the
external/internal group), then internal workspace packages if any, and finally
local relative imports (move "import type { AuthServiceContext } from
'../context.js'" and the imports from "../lib/client-metadata.js" and
"../lib/resolve-login-hint.js" to the end). Ensure symbols like randomBytes,
Router, escapeHtml, socialProviders, resolveClientMetadata and resolveLoginHint
remain unchanged while reordering only the import statements.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/shared/src/otp-config.ts`:
- Around line 25-42: The shared resolveOtpConfig currently allows variable
lengths and formats which can change auth-service behavior at deploy; fix it so
resolveOtpConfig() always returns { length: 8, format: 'numeric' } (remove
parsing/validation of OTP_LENGTH and OTP_FORMAT) so auth-service remains fixed
to 8-digit numeric OTPs, and if configurable OTPs are needed create a separate
helper (e.g., resolveConfigForFlexibleOtp or generateConfigurableOtp) for other
packages instead of altering resolveOtpConfig; ensure generateOtp() continues to
consume resolveOtpConfig() unchanged so it emits 8-digit numeric codes for
auth-service.

---

Nitpick comments:
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 23-42: The import block is misordered: bring Node built-ins first
(keep "import { randomBytes } from 'node:crypto'"), then external packages (move
"import { Router, type Request, type Response } from 'express'" and "import {
escapeHtml, createLogger, otpHtmlAttrs, otpDescriptionText } from
'@certified-app/shared'" and "import { socialProviders } from
'../better-auth.js'" into the external/internal group), then internal workspace
packages if any, and finally local relative imports (move "import type {
AuthServiceContext } from '../context.js'" and the imports from
"../lib/client-metadata.js" and "../lib/resolve-login-hint.js" to the end).
Ensure symbols like randomBytes, Router, escapeHtml, socialProviders,
resolveClientMetadata and resolveLoginHint remain unchanged while reordering
only the import statements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d1ad56f-7082-4b60-aa6a-9decdab5dca0

📥 Commits

Reviewing files that changed from the base of the PR and between 0356474 and 976e670.

📒 Files selected for processing (5)
  • .beads/.gitignore
  • .beads/issues.jsonl
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/recovery.ts
  • packages/shared/src/otp-config.ts
✅ Files skipped from review due to trivial changes (1)
  • .beads/.gitignore
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/auth-service/src/routes/recovery.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants