Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
import { OAuthClientMetadata } from '@atproto/oauth-types'
import { extractHue, pickContrastColor } from '../lib/util/color.js'
import { Branding } from './branding.js'
import { COLOR_NAMES } from './colors.js'
import { Customization } from './customization.js'

export function buildCustomizationCss({
branding,
}: Customization): undefined | string {
clientMetadata,
isTrusted,
}: Customization & {
clientMetadata?: OAuthClientMetadata
isTrusted?: boolean
}): undefined | string {
// Build CSS variables from PDS branding
const vars = Array.from(buildCustomizationVars(branding))
if (vars.length) return `:root { ${vars.join(' ')} }`

// Get arbitrary CSS from trusted clients only
const customCss =
isTrusted && clientMetadata?.branding?.css
? clientMetadata.branding.css
: undefined

// Build the final CSS string
const cssParts: string[] = []

if (vars.length) {
cssParts.push(`:root { ${vars.join(' ')} }`)
}

if (customCss) {
cssParts.push(customCss)
}

return cssParts.length > 0 ? cssParts.join('\n') : undefined
}

function* buildCustomizationVars(branding?: Branding): Generator<string> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { setupCsrfToken } from './csrf.js'
export function sendAuthorizePageFactory(customization: Customization) {
// Pre-computed options:
const customizationData = buildCustomizationData(customization)
const customizationCss = cssCode(buildCustomizationCss(customization))
const { scripts, styles } = getAssets('authorization-page')
const csp = mergeCsp(
SPA_CSP,
Expand All @@ -33,6 +32,15 @@ export function sendAuthorizePageFactory(customization: Customization) {
): Promise<void> {
await setupCsrfToken(req, res)

// Generate CSS with client branding
const customizationCss = cssCode(
buildCustomizationCss({
branding: customization.branding,
clientMetadata: data.client.metadata,
isTrusted: data.client.info.isTrusted,
}),
)
Comment on lines +35 to +42

Copilot AI Jan 10, 2026

Copy link

Choose a reason for hiding this comment

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

The CSS generation has been moved from pre-computation (line 17 in the factory function) to per-request computation inside the handler function. This creates a performance regression because buildCustomizationCss will be called for every authorization request instead of once when the factory is initialized.

For authorization pages that don't use client branding (non-trusted clients or clients without branding), this adds unnecessary overhead on every request. Consider maintaining the pre-computed customizationCss for the base case and only computing client-specific CSS when needed.

Copilot uses AI. Check for mistakes.

@aspiers aspiers Jan 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Filed as #14 to be done as a follow-up.


const script = declareHydrationData<HydrationData['authorization-page']>({
__customizationData: customizationData,
__authorizeData: {
Expand Down
17 changes: 17 additions & 0 deletions packages/oauth/oauth-types/src/oauth-client-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export const oauthClientMetadataSchema = z.object({

// https://datatracker.ietf.org/doc/html/rfc9396#section-14.5
authorization_details_types: z.array(z.string()).optional(),

/**
* Client-specific branding configuration
* Allows trusted clients to customize the appearance of OAuth authorization pages
* via arbitrary CSS. Only used for trusted clients (configured via trustedClients in PDS config)
* @see {@link https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/config.ts}
*/
branding: z
.object({
/**
* Arbitrary CSS to inject into authorization pages
* Only used for trusted clients (configured via trustedClients in PDS config)
* @see {@link https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/config.ts}
*/
css: z.string().optional(),
})
.optional(),
})

export type OAuthClientMetadata = z.infer<typeof oauthClientMetadataSchema>
Expand Down
28 changes: 28 additions & 0 deletions packages/sds-demo/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,34 @@ module.exports = defineConfig((commandLineArguments) => {
application_type: 'web',
token_endpoint_auth_method: 'none',
dpop_bound_access_tokens: true,
// Custom branding for trusted clients
// This CSS will be injected into the authorization page when this client is trusted
branding: {
css: `
/* SDS Demo Custom Branding */
:root {
--branding-color-primary: 99 102 241;
--branding-color-primary-contrast: 255 255 255;
--branding-color-primary-hue: 239;
}

/* Custom button styling */
button[type="submit"] {
background: linear-gradient(135deg, rgb(99 102 241) 0%, rgb(139 92 246) 100%) !important;
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.39) !important;
}

/* Add a subtle brand indicator */
.auth-form::before {
content: "Powered by SDS Demo";
display: block;
text-align: center;
font-size: 0.75rem;
color: rgb(99 102 241);
margin-bottom: 1rem;
}
Comment on lines +193 to +201

Copilot AI Jan 10, 2026

Copy link

Choose a reason for hiding this comment

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

The pseudo-element selector '.auth-form::before' assumes that an element with class 'auth-form' exists in the authorization page markup. If this class doesn't exist or is renamed in the future, the branding indicator won't be displayed. Consider documenting the expected DOM structure for custom CSS, or use more defensive CSS that won't break if the structure changes.

Copilot uses AI. Check for mistakes.
`.trim(),
},
}

this.emitFile({
Expand Down