Skip to content

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

Closed
aspiers wants to merge 9 commits intomainfrom
css-injection-trusted-clients
Closed

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

Conversation

@aspiers
Copy link
Copy Markdown
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.

@vercel
Copy link
Copy Markdown

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 Apr 10, 2026 2:19am

Request Review

@coderabbitai
Copy link
Copy Markdown

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 '[OLD][HYPER-109] feat: custom CSS injection for trusted OAuth clients' accurately summarizes the main feature being added: CSS injection for trusted OAuth clients with support for branding customization.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • 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.

Copy link
Copy Markdown

@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 thread packages/auth-service/src/routes/login-page.ts Outdated
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
Copy Markdown

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 thread packages/shared/src/index.ts Outdated
Copy link
Copy Markdown

@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

Comment thread .beads/issues.jsonl
Comment thread .beads/issues.jsonl Outdated
Copy link
Copy Markdown

@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 thread packages/auth-service/src/routes/recovery.ts Outdated
Comment thread packages/pds-core/src/index.ts
Comment thread packages/shared/src/otp-config.ts
Copy link
Copy Markdown

@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

Comment thread packages/auth-service/src/routes/login-page.ts Outdated
Comment thread packages/auth-service/src/routes/login-page.ts Outdated
@Kzoeps
Copy link
Copy Markdown
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.

Update to this: I think it might be better to use the flow id and have it be consistent with the rest of how its done

Copy link
Copy Markdown

@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

aspiers and others added 7 commits April 10, 2026 01:56
…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>
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>
Wire up three active scenarios in features/client-branding.feature:

- Trusted client's custom CSS is present in the login page HTML
- Trusted and untrusted demos produce visibly different login pages
  (computed body background colors differ)
- Untrusted client gets no CSS injection and renders with the default
  background color

Also drop the CSP-hash assertion from the trusted-login scenario — the
auth-service login-page template doesn't wire a CSP style-src hash (only
the pds-core middleware on /oauth/authorize does). The consent-page CSS
scenario is marked @Manual until we have a returning-user reauth fixture
to reach the upstream consent UI.

Relies on E2E_DEMO_URL and E2E_DEMO_UNTRUSTED_URL pointing at two
separately-deployed instances of packages/demo, only one of which is
listed in PDS_OAUTH_TRUSTED_CLIENTS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

aspiers added a commit that referenced this pull request Apr 10, 2026
Wire ePDS for cross-client session reuse so a user who has signed in
once via any OAuth client is not re-prompted for email OTP by a second
client in the same browser. Three changes working together, guarded by
the new PDS_COOKIE_DOMAIN env var — deployments that don't set it get
the current behaviour unchanged.

1. pds-core: broaden device-session cookie domain

When PDS_COOKIE_DOMAIN is set, inject Domain=<parent> into outbound
Set-Cookie headers for dev-id, ses-id, and their :hash siblings.
Upstream @atproto/oauth-provider's DeviceManager has no domain
option and scopes the cookies host-only by default; rewriting the
headers makes the cookies visible to sibling subdomains like
auth.pds.foo.com.

2. auth-service: skip the email form when a device session is present

At the top of GET /oauth/authorize, check for a dev-id cookie on the
request. If present and prompt=login is not set, 302 to pds-core's
upstream /oauth/authorize with the same query string so upstream's
middleware can either auto-select the matching session (flow 1,
login_hint supplied) or render the account chooser (flow 2). If
absent, render the email form exactly as today.

3. pds-core: enrich the upstream account chooser

Reuse PR #9's response-rewrite pattern to inject a small script into
/account* HTML responses. The script captures the __deviceSessions
hydration payload (which already carries each account's email) via
an accessor on window, then uses a MutationObserver to (a) append
each account's email alongside the rendered handle row and (b) add
a "Use a different account" link pointing at auth.<host>/oauth/
authorize?prompt=login — the escape hatch that sends users back to
the email form when they want to sign in as someone else.

Scenario coverage in features/passwordless-authentication.feature
(committed earlier) exercises all four session-reuse branches:
flow 1 auto-skip, flow 2 chooser-with-confirm, flow 2 pre-approved
auto-authorize, and flow 2 "use a different account". Design doc
at docs/design/hyper-268-session-reuse.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 10, 2026
Wire ePDS for cross-client session reuse so a user who has signed in
once via any OAuth client is not re-prompted for email OTP by a second
client in the same browser. Three changes working together, guarded by
the new PDS_COOKIE_DOMAIN env var — deployments that don't set it get
the current behaviour unchanged.

1. pds-core: broaden device-session cookie domain

When PDS_COOKIE_DOMAIN is set, inject Domain=<parent> into outbound
Set-Cookie headers for dev-id, ses-id, and their :hash siblings.
Upstream @atproto/oauth-provider's DeviceManager has no domain
option and scopes the cookies host-only by default; rewriting the
headers makes the cookies visible to sibling subdomains like
auth.pds.foo.com.

2. auth-service: skip the email form when a device session is present

At the top of GET /oauth/authorize, check for a dev-id cookie on the
request. If present and prompt=login is not set, 302 to pds-core's
upstream /oauth/authorize with the same query string so upstream's
middleware can either auto-select the matching session (flow 1,
login_hint supplied) or render the account chooser (flow 2). If
absent, render the email form exactly as today.

3. pds-core: enrich the upstream account chooser

Reuse PR #9's response-rewrite pattern to inject a small script into
/account* HTML responses. The script captures the __deviceSessions
hydration payload (which already carries each account's email) via
an accessor on window, then uses a MutationObserver to (a) append
each account's email alongside the rendered handle row and (b) add
a "Use a different account" link pointing at auth.<host>/oauth/
authorize?prompt=login — the escape hatch that sends users back to
the email form when they want to sign in as someone else.

Scenario coverage in features/passwordless-authentication.feature
(committed earlier) exercises all four session-reuse branches:
flow 1 auto-skip, flow 2 chooser-with-confirm, flow 2 pre-approved
auto-authorize, and flow 2 "use a different account". Design doc
at docs/design/hyper-268-session-reuse.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 10, 2026
Wire ePDS for cross-client session reuse so a user who has signed in
once via any OAuth client is not re-prompted for email OTP by a second
client in the same browser. Three changes working together, guarded by
the new PDS_COOKIE_DOMAIN env var — deployments that don't set it get
the current behaviour unchanged.

1. pds-core: broaden device-session cookie domain

When PDS_COOKIE_DOMAIN is set, inject Domain=<parent> into outbound
Set-Cookie headers for dev-id, ses-id, and their :hash siblings.
Upstream @atproto/oauth-provider's DeviceManager has no domain
option and scopes the cookies host-only by default; rewriting the
headers makes the cookies visible to sibling subdomains like
auth.pds.foo.com.

2. auth-service: skip the email form when a device session is present

At the top of GET /oauth/authorize, check for a dev-id cookie on the
request. If present and prompt=login is not set, 302 to pds-core's
upstream /oauth/authorize with the same query string so upstream's
middleware can either auto-select the matching session (flow 1,
login_hint supplied) or render the account chooser (flow 2). If
absent, render the email form exactly as today.

3. pds-core: enrich the upstream account chooser

Reuse PR #9's response-rewrite pattern to inject a small script into
/account* HTML responses. The script captures the __deviceSessions
hydration payload (which already carries each account's email) via
an accessor on window, then uses a MutationObserver to (a) append
each account's email alongside the rendered handle row and (b) add
a "Use a different account" link pointing at auth.<host>/oauth/
authorize?prompt=login — the escape hatch that sends users back to
the email form when they want to sign in as someone else.

Scenario coverage in features/passwordless-authentication.feature
(committed earlier) exercises all four session-reuse branches:
flow 1 auto-skip, flow 2 chooser-with-confirm, flow 2 pre-approved
auto-authorize, and flow 2 "use a different account". Design doc
at docs/design/hyper-268-session-reuse.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 10, 2026
Port the CSS injection e2e scenarios from the css-injection-trusted-clients
branch (PR #9, commit ba74e45) and adapt them to the tagging and
skippability infrastructure on this branch.

Three active CSS-injection scenarios:

1. "Trusted client's CSS is applied to the login page" — checks that
   the injected <style> tag with the demo's branding CSS appears in
   the login page HTML when the trusted demo client initiates OAuth.
2. "Trusted and untrusted demo clients render visibly differently" —
   captures computed body background color from both demos and asserts
   they differ. Tagged @untrusted-client.
3. "Untrusted client does not get CSS injection" — asserts the
   injected CSS is absent and the default background color is used.
   Tagged @untrusted-client.

The consent-page CSS scenario is marked @Manual (needs a reauth
fixture to reach the upstream PDS consent UI). The 6 email-template
scenarios are tagged @not-implemented individually (the email-template
feature does not exist yet).

The Feature-level @not-implemented tag is removed since the CSS
scenarios now have step definitions. Background is enriched with the
trusted demo metadata discoverability check to match
consent-screen.feature's pattern.

Step definitions are in e2e/step-definitions/client-branding.steps.ts.
The two untrusted-client scenarios reuse the shared "the untrusted
demo client initiates an OAuth login" step from consent.steps.ts,
which already has the if (!testEnv.demoUntrustedUrl) return 'pending'
guard from the previous commit. A capturedBgColors property is added
to EpdsWorld for the computed-color comparison scenario.

Dry-run:
- With E2E_DEMO_UNTRUSTED_URL:    22 scenarios (19 + 3 new)
- Without:                         14 scenarios (13 + 1 trusted-only)
aspiers added a commit that referenced this pull request Apr 10, 2026
Port the CSS injection e2e scenarios from the css-injection-trusted-clients
branch (PR #9, commit ba74e45) and adapt them to the tagging and
skippability infrastructure on this branch.

Three active CSS-injection scenarios:

1. "Trusted client's CSS is applied to the login page" — checks that
   the injected <style> tag with the demo's branding CSS appears in
   the login page HTML when the trusted demo client initiates OAuth.
2. "Trusted and untrusted demo clients render visibly differently" —
   captures computed body background color from both demos and asserts
   they differ. Tagged @untrusted-client.
3. "Untrusted client does not get CSS injection" — asserts the
   injected CSS is absent and the default background color is used.
   Tagged @untrusted-client.

The consent-page CSS scenario is marked @Manual (needs a reauth
fixture to reach the upstream PDS consent UI). The 6 email-template
scenarios are tagged @not-implemented individually (the email-template
feature does not exist yet).

The Feature-level @not-implemented tag is removed since the CSS
scenarios now have step definitions. Background is enriched with the
trusted demo metadata discoverability check to match
consent-screen.feature's pattern.

Step definitions are in e2e/step-definitions/client-branding.steps.ts.
The two untrusted-client scenarios reuse the shared "the untrusted
demo client initiates an OAuth login" step from consent.steps.ts,
which already has the if (!testEnv.demoUntrustedUrl) return 'pending'
guard from the previous commit. A capturedBgColors property is added
to EpdsWorld for the computed-color comparison scenario.

Dry-run:
- With E2E_DEMO_UNTRUSTED_URL:    22 scenarios (19 + 3 new)
- Without:                         14 scenarios (13 + 1 trusted-only)
aspiers added a commit that referenced this pull request Apr 11, 2026
After a user signs in once via any OAuth client, a second client in the
same browser no longer re-prompts for email OTP. ePDS recognises the
existing device session and either auto-redirects (flow 1, login_hint
matches) or shows the upstream account chooser (flow 2).

Three pieces working together:

1. pds-core: broaden device-session cookie domain
   Auto-derived from AUTH_HOSTNAME / PDS_HOSTNAME: if AUTH_HOSTNAME ends
   with .<PDS_HOSTNAME>, inject Domain=<PDS_HOSTNAME> into outbound
   Set-Cookie headers for dev-id/ses-id and their :hash sidecars.
   On Railway preview envs (unrelated hostnames under a public suffix)
   the check fails and the middleware is silently skipped.

2. auth-service: skip the email form when a device session exists
   GET /oauth/authorize checks for a dev-id cookie. If present and
   prompt=login is not set, 302s to pds-core's upstream /oauth/authorize.
   If absent, renders the email form as today.

3. pds-core: enrich the upstream account chooser
   Response-rewrite middleware (extending PR #9's pattern) injects a
   script into /account* HTML responses that surfaces each account's
   email alongside the handle and adds a "Use a different account" link
   pointing at auth.<host>/oauth/authorize?prompt=login.

Also includes:
- 4 Gherkin scenarios (tagged @session-reuse, excluded from default CI)
- 30 unit tests for the extracted pure helpers
- Design doc at docs/design/hyper-268-session-reuse.md
- Changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 11, 2026
After a user signs in once via any OAuth client, a second client in the
same browser no longer re-prompts for email OTP. ePDS recognises the
existing device session and either auto-redirects (flow 1, login_hint
matches) or shows the upstream account chooser (flow 2).

Three pieces working together:

1. pds-core: broaden device-session cookie domain
   Auto-derived from AUTH_HOSTNAME / PDS_HOSTNAME: if AUTH_HOSTNAME ends
   with .<PDS_HOSTNAME>, inject Domain=<PDS_HOSTNAME> into outbound
   Set-Cookie headers for dev-id/ses-id and their :hash sidecars.
   On Railway preview envs (unrelated hostnames under a public suffix)
   the check fails and the middleware is silently skipped.

2. auth-service: skip the email form when a device session exists
   GET /oauth/authorize checks for a dev-id cookie. If present and
   prompt=login is not set, 302s to pds-core's upstream /oauth/authorize.
   If absent, renders the email form as today.

3. pds-core: enrich the upstream account chooser
   Response-rewrite middleware (extending PR #9's pattern) injects a
   script into /account* HTML responses that surfaces each account's
   email alongside the handle and adds a "Use a different account" link
   pointing at auth.<host>/oauth/authorize?prompt=login.

Also includes:
- 4 Gherkin scenarios (tagged @session-reuse, excluded from default CI)
- 30 unit tests for the extracted pure helpers
- Design doc at docs/design/hyper-268-session-reuse.md
- Changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 11, 2026
…urfaces

PR #9 injects trusted-client CSS into the auth-service login page and
the pds-core /oauth/authorize consent page, but four other auth-service
HTML pages are not yet covered:

- Auth-service consent page (HYPER-296)
- Choose-handle page (HYPER-297)
- Recovery page (HYPER-298)
- Account settings page (HYPER-299)

Add @not-implemented scenarios in features/client-branding.feature for
each. These document the expected behaviour and will be activated when
the corresponding injection code is written.
aspiers added a commit that referenced this pull request Apr 11, 2026
After a user signs in once via any OAuth client, a second client in the
same browser no longer re-prompts for email OTP. ePDS recognises the
existing device session and either auto-redirects (flow 1, login_hint
matches) or shows the upstream account chooser (flow 2).

Three pieces working together:

1. pds-core: broaden device-session cookie domain
   Auto-derived from AUTH_HOSTNAME / PDS_HOSTNAME: if AUTH_HOSTNAME ends
   with .<PDS_HOSTNAME>, inject Domain=<PDS_HOSTNAME> into outbound
   Set-Cookie headers for dev-id/ses-id and their :hash sidecars.
   On Railway preview envs (unrelated hostnames under a public suffix)
   the check fails and the middleware is silently skipped.

2. auth-service: skip the email form when a device session exists
   GET /oauth/authorize checks for a dev-id cookie. If present and
   prompt=login is not set, 302s to pds-core's upstream /oauth/authorize.
   If absent, renders the email form as today.

3. pds-core: enrich the upstream account chooser
   Response-rewrite middleware (extending PR #9's pattern) injects a
   script into /account* HTML responses that surfaces each account's
   email alongside the handle and adds a "Use a different account" link
   pointing at auth.<host>/oauth/authorize?prompt=login.

Also includes:
- 4 Gherkin scenarios (tagged @session-reuse, excluded from default CI)
- 30 unit tests for the extracted pure helpers
- Design doc at docs/design/hyper-268-session-reuse.md
- Changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 11, 2026
After a user signs in once via any OAuth client, a second client in the
same browser no longer re-prompts for email OTP. ePDS recognises the
existing device session and either auto-redirects (flow 1, login_hint
matches) or shows the upstream account chooser (flow 2).

Three pieces working together:

1. pds-core: broaden device-session cookie domain
   Auto-derived from AUTH_HOSTNAME / PDS_HOSTNAME: if AUTH_HOSTNAME ends
   with .<PDS_HOSTNAME>, inject Domain=<PDS_HOSTNAME> into outbound
   Set-Cookie headers for dev-id/ses-id and their :hash sidecars.
   On Railway preview envs (unrelated hostnames under a public suffix)
   the check fails and the middleware is silently skipped.

2. auth-service: skip the email form when a device session exists
   GET /oauth/authorize checks for a dev-id cookie. If present and
   prompt=login is not set, 302s to pds-core's upstream /oauth/authorize.
   If absent, renders the email form as today.

3. pds-core: enrich the upstream account chooser
   Response-rewrite middleware (extending PR #9's pattern) injects a
   script into /account* HTML responses that surfaces each account's
   email alongside the handle and adds a "Use a different account" link
   pointing at auth.<host>/oauth/authorize?prompt=login.

Also includes:
- 4 Gherkin scenarios (tagged @session-reuse, excluded from default CI)
- 30 unit tests for the extracted pure helpers
- Design doc at docs/design/hyper-268-session-reuse.md
- Changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 11, 2026
After a user signs in once via any OAuth client, a second client in the
same browser no longer re-prompts for email OTP. ePDS recognises the
existing device session and either auto-redirects (flow 1, login_hint
matches) or shows the upstream account chooser (flow 2).

Three pieces working together:

1. pds-core: broaden device-session cookie domain
   Auto-derived from AUTH_HOSTNAME / PDS_HOSTNAME: if AUTH_HOSTNAME ends
   with .<PDS_HOSTNAME>, inject Domain=<PDS_HOSTNAME> into outbound
   Set-Cookie headers for dev-id/ses-id and their :hash sidecars.
   On Railway preview envs (unrelated hostnames under a public suffix)
   the check fails and the middleware is silently skipped.

2. auth-service: skip the email form when a device session exists
   GET /oauth/authorize checks for a dev-id cookie. If present and
   prompt=login is not set, 302s to pds-core's upstream /oauth/authorize.
   If absent, renders the email form as today.

3. pds-core: enrich the upstream account chooser
   Response-rewrite middleware (extending PR #9's pattern) injects a
   script into /account* HTML responses that surfaces each account's
   email alongside the handle and adds a "Use a different account" link
   pointing at auth.<host>/oauth/authorize?prompt=login.

Also includes:
- 4 Gherkin scenarios (tagged @session-reuse, excluded from default CI)
- 30 unit tests for the extracted pure helpers
- Design doc at docs/design/hyper-268-session-reuse.md
- Changeset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 13, 2026
Port the CSS injection e2e scenarios from the css-injection-trusted-clients
branch (PR #9, commit ba74e45) and adapt them to the tagging and
skippability infrastructure on this branch.

Three active CSS-injection scenarios:

1. "Trusted client's CSS is applied to the login page" — checks that
   the injected <style> tag with the demo's branding CSS appears in
   the login page HTML when the trusted demo client initiates OAuth.
2. "Trusted and untrusted demo clients render visibly differently" —
   captures computed body background color from both demos and asserts
   they differ. Tagged @untrusted-client.
3. "Untrusted client does not get CSS injection" — asserts the
   injected CSS is absent and the default background color is used.
   Tagged @untrusted-client.

The consent-page CSS scenario is marked @Manual (needs a reauth
fixture to reach the upstream PDS consent UI). The 6 email-template
scenarios are tagged @not-implemented individually (the email-template
feature does not exist yet).

The Feature-level @not-implemented tag is removed since the CSS
scenarios now have step definitions. Background is enriched with the
trusted demo metadata discoverability check to match
consent-screen.feature's pattern.

Step definitions are in e2e/step-definitions/client-branding.steps.ts.
The two untrusted-client scenarios reuse the shared "the untrusted
demo client initiates an OAuth login" step from consent.steps.ts,
which already has the if (!testEnv.demoUntrustedUrl) return 'pending'
guard from the previous commit. A capturedBgColors property is added
to EpdsWorld for the computed-color comparison scenario.

Dry-run:
- With E2E_DEMO_UNTRUSTED_URL:    22 scenarios (19 + 3 new)
- Without:                         14 scenarios (13 + 1 trusted-only)
aspiers added a commit that referenced this pull request Apr 13, 2026
…urfaces

PR #9 injects trusted-client CSS into the auth-service login page and
the pds-core /oauth/authorize consent page, but four other auth-service
HTML pages are not yet covered:

- Auth-service consent page (HYPER-296)
- Choose-handle page (HYPER-297)
- Recovery page (HYPER-298)
- Account settings page (HYPER-299)

Add @not-implemented scenarios in features/client-branding.feature for
each. These document the expected behaviour and will be activated when
the corresponding injection code is written.
@aspiers aspiers changed the title [HYPER-109] feat: custom CSS injection for trusted OAuth clients [OLD][HYPER-109] feat: custom CSS injection for trusted OAuth clients Apr 13, 2026
@aspiers
Copy link
Copy Markdown
Contributor Author

aspiers commented Apr 13, 2026

Superseded by #48

@aspiers aspiers closed this Apr 13, 2026
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