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

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

PDS_EMAIL_SMTP_URL=smtp://localhost:1025
PDS_EMAIL_FROM_ADDRESS=noreply@pds.example
PDS_BLOBSTORE_DISK_LOCATION=/data/blobs
Expand Down
128 changes: 128 additions & 0 deletions e2e/step-definitions/client-branding.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Step definitions for features/client-branding.feature.
*
* Exercises the CSS injection path that lets trusted OAuth clients ship a
* `branding.css` block in their client-metadata.json and have it inlined
* into the auth-service login page. The untrusted demo client serves the
* same metadata but is not in PDS_OAUTH_TRUSTED_CLIENTS, so injection must
* be suppressed — that asymmetry is what these scenarios verify end-to-end.
*/
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { testEnv } from '../support/env.js'
import type { EpdsWorld } from '../support/world.js'
import { getPage, resetBrowserContext } from '../support/utils.js'
import { sharedBrowser } from '../support/hooks.js'

// A short, stable fragment from the demo's branding.css. Any substring that
// only the injected CSS would produce works — we pick the dark body bg since
// it's also what makes the visual difference in the comparison scenario.
const INJECTED_CSS_SIGNATURE = 'body { background: #0f172a'

// Default background declared in the auth-service login-page template when
// no branding CSS is injected. Matches demo metadata's `background_color`.
const DEFAULT_LOGIN_BG_RGB = 'rgb(248, 249, 250)' // #f8f9fa

async function waitForLoginPage(world: EpdsWorld): Promise<void> {
const page = getPage(world)
await expect(page.locator('#email')).toBeVisible({ timeout: 30_000 })
}

// ---------------------------------------------------------------------------
// Navigation — trusted vs. untrusted demo OAuth start
// ---------------------------------------------------------------------------

When(
'the trusted demo client initiates an OAuth login',
async function (this: EpdsWorld) {
await this.page?.goto(testEnv.demoTrustedUrl)
await waitForLoginPage(this)
},
)

// Note: "the untrusted demo client initiates an OAuth login" is already
// defined in consent.steps.ts (it navigates but does not wait for the
// login page to be interactive). "the demo client initiates an OAuth
// login" is defined in auth.steps.ts and targets the trusted demo.
// We reuse both rather than duplicating them here.

// ---------------------------------------------------------------------------
// HTML-level assertions — <style> tag content
// ---------------------------------------------------------------------------

Then(
"the login page HTML contains the trusted client's custom CSS",
async function (this: EpdsWorld) {
await waitForLoginPage(this)
const html = await getPage(this).content()
expect(html).toContain(INJECTED_CSS_SIGNATURE)
},
)

Then(
"the login page HTML does not contain the trusted client's custom CSS",
async function (this: EpdsWorld) {
await waitForLoginPage(this)
const html = await getPage(this).content()
expect(html).not.toContain(INJECTED_CSS_SIGNATURE)
},
)

Then(
'the login page body uses the default background color',
async function (this: EpdsWorld) {
await waitForLoginPage(this)
const bg = await getPage(this)
.locator('body')
.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(bg).toBe(DEFAULT_LOGIN_BG_RGB)
},
)

// ---------------------------------------------------------------------------
// Visual-difference assertion — computed background colors differ
// ---------------------------------------------------------------------------

When(
'the login page body background color is captured as {string}',
async function (this: EpdsWorld, label: string) {
await waitForLoginPage(this)
const bg = await getPage(this)
.locator('body')
.evaluate((el) => getComputedStyle(el).backgroundColor)
const store = (this.capturedBgColors ??= {})
store[label] = bg
},
)

When('the browser session is reset', async function (this: EpdsWorld) {
await resetBrowserContext(this, sharedBrowser)
})

Then(
'the captured {string} and {string} background colors differ',
function (this: EpdsWorld, a: string, b: string) {
const store = this.capturedBgColors ?? {}
const colorA = store[a]
const colorB = store[b]
expect(colorA, `no background color captured for "${a}"`).toBeTruthy()
expect(colorB, `no background color captured for "${b}"`).toBeTruthy()
expect(
colorA,
`expected "${a}" (${colorA}) and "${b}" (${colorB}) to differ`,
).not.toBe(colorB)
},
)

// ---------------------------------------------------------------------------
// Manual / placeholder Givens referenced by the @manual consent scenario.
// Defined so Cucumber doesn't emit "undefined step" warnings on dry runs.
// ---------------------------------------------------------------------------

Given(
'the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS',
function (this: EpdsWorld) {
// Environmental precondition — asserted implicitly by other scenarios
// that observe CSS injection working. No runtime assertion here.
},
)
3 changes: 3 additions & 0 deletions e2e/support/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export class EpdsWorld extends World {
/** Most recent PAR request_uri — set by PAR submission steps. */
lastRequestUri?: string

/** Computed background colors captured by client-branding scenarios, keyed by label. */
capturedBgColors?: Record<string, string>

get env() {
return testEnv
}
Expand Down
33 changes: 22 additions & 11 deletions features/client-branding.feature
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,32 @@ Feature: Client branding — CSS injection and custom email templates
# --- CSS injection ---

Scenario: Trusted client's CSS is applied to the login page
Given the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS
And the demo client's metadata includes a "branding.css" URL
When the demo client initiates an OAuth login
Then the login page includes the client's custom CSS in a <style> tag
And the Content-Security-Policy header includes the CSS hash in style-src
Then the login page HTML contains the trusted client's custom CSS

Scenario: Trusted client's CSS is applied to the consent page
Given the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS
When an existing user reaches the consent screen via the demo client
Then the consent page includes the client's custom CSS
Scenario: Trusted and untrusted demo clients render visibly differently
When the trusted demo client initiates an OAuth login
And the login page body background color is captured as "trusted"
And the browser session is reset
And the untrusted demo client initiates an OAuth login
And the login page body background color is captured as "untrusted"
Then the captured "trusted" and "untrusted" background colors differ

Scenario: Untrusted client does not get CSS injection
Given "https://untrusted.example.com" is NOT in PDS_OAUTH_TRUSTED_CLIENTS
When a login flow is initiated by the untrusted client
Then the login page uses the default styling (no injected CSS)
When the untrusted demo client initiates an OAuth login
Then the login page HTML does not contain the trusted client's custom CSS
And the login page body uses the default background color

@manual
Scenario: Trusted client's CSS is applied to the upstream OAuth consent page
# Exercises the pds-core CSS-injection middleware on /oauth/authorize.
# Requires a returning user re-authorizing through the upstream consent UI
# (trusted clients skip consent on initial sign-up, so this path only
# fires on reauth). Manual until we have a reauth fixture.
Given the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS
When an existing user reaches the upstream consent screen via the demo client
Then the consent page HTML contains the trusted client's custom CSS
And the Content-Security-Policy style-src directive includes the CSS SHA-256 hash

# --- Custom email templates ---

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

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

# -- Auth-service only --

# Subdomain for the auth UI (must be a subdomain of PDS_HOSTNAME)
Expand Down
2 changes: 2 additions & 0 deletions packages/auth-service/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface AuthServiceConfig {
dbLocation: string
otpLength: number
otpCharset: 'numeric' | 'alphanumeric'
/** OAuth client_id URLs trusted for CSS branding injection. */
trustedClients: string[]
}

const logger = createLogger('auth-service')
Expand Down
4 changes: 4 additions & 0 deletions packages/auth-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ async function main() {
otpCharset: (process.env.OTP_CHARSET || 'numeric') as
| 'numeric'
| 'alphanumeric',
trustedClients: (process.env.PDS_OAUTH_TRUSTED_CLIENTS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
}

if (
Expand Down
9 changes: 7 additions & 2 deletions packages/auth-service/src/lib/client-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@
* Auth-service imports are preserved for backwards compatibility.
*/

export { resolveClientMetadata, resolveClientName } from '@certified-app/shared'
export type { ClientMetadata } from '@certified-app/shared'
export {
resolveClientMetadata,
resolveClientName,
escapeCss,
getClientCss,
} from '@certified-app/shared'
export type { ClientMetadata, ClientBranding } from '@certified-app/shared'
10 changes: 9 additions & 1 deletion packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { AuthServiceContext } from '../context.js'
import {
resolveClientMetadata,
resolveClientName,
getClientCss,
type ClientMetadata,
} from '../lib/client-metadata.js'
import {
Expand Down Expand Up @@ -158,6 +159,11 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router {
clientMeta.client_name ??
(clientId ? await resolveClientName(clientId) : 'an application')

// CSS injection for trusted clients
const customCss = clientId
? getClientCss(clientId, clientMeta, ctx.config.trustedClients)
: null

// Pillar 1 — State Determination: decide which step to render based on
// login_hint presence. No method-assuming side effects in the GET handler.
// The login_hint may be:
Expand Down Expand Up @@ -219,6 +225,7 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router {
clientId: clientId ?? '',
clientName,
branding: clientMeta,
customCss,
loginHint: emailHint,
initialStep,
otpAlreadySent,
Expand All @@ -239,6 +246,7 @@ function renderLoginPage(opts: {
clientId: string
clientName: string
branding: ClientMetadata
customCss: string | null
loginHint: string
initialStep: 'email' | 'otp'
otpAlreadySent: boolean
Expand Down Expand Up @@ -330,7 +338,7 @@ function renderLoginPage(opts: {
.step-email.hidden { display: none; }
.recovery-link { display: block; margin-top: 16px; color: #888; font-size: 13px; text-decoration: none; }
.recovery-link:hover { color: #555; }
</style>
</style>${opts.customCss ? `\n <style>${opts.customCss}</style>` : ''}
</head>
<body>
<div class="container">
Expand Down
21 changes: 21 additions & 0 deletions packages/demo/src/app/client-metadata.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ export async function GET() {
...(process.env.EPDS_SKIP_CONSENT_ON_SIGNUP === 'true' && {
epds_skip_consent_on_signup: true,
}),
branding: {
css: [
'body { background: #0f172a; color: #e2e8f0; }',
'h1 { color: #f1f5f9; }',
'.subtitle { color: #94a3b8; }',
'.field label { color: #cbd5e1; }',
'.field input { background: #1e293b; border-color: #334155; color: #f1f5f9; }',
'.field input:focus { border-color: #7c3aed; }',
'.otp-input:focus { border-color: #7c3aed !important; }',
'.btn-primary { background: linear-gradient(135deg, #2563eb, #7c3aed); }',
'.btn-primary:hover { opacity: 0.95; }',
'.btn-secondary { color: #94a3b8; }',
'.btn-social { background: #1e293b; border-color: #334155; color: #e2e8f0; }',
'.btn-social:hover { background: #334155; }',
'.divider { color: #64748b; }',
'.divider::before, .divider::after { background: #334155; }',
'.error { background: #450a0a; color: #fca5a5; }',
'.recovery-link { color: #64748b; }',
'.recovery-link:hover { color: #94a3b8; }',
].join(' '),
},
}

return NextResponse.json(metadata, {
Expand Down
4 changes: 4 additions & 0 deletions packages/pds-core/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=
# Option 2: Disable the requirement entirely (not recommended for production)
# PDS_INVITE_REQUIRED=false

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

# PDS email (used by @atproto/pds for password reset, confirm-email, etc.)
# SMTP URL in the form smtp://user:pass@host:port or smtps://user:pass@host:465
PDS_EMAIL_SMTP_URL=smtp://localhost:1025
Expand Down
Loading
Loading