Skip to content

Commit 40ff9ce

Browse files
authored
Merge pull request #48 from hypercerts-org/css-injection-trusted-clients-testing
2 parents 2ab701f + 407ac4e commit 40ff9ce

66 files changed

Lines changed: 4131 additions & 1402 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/reference/SonarCloud.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# SonarCloud
2+
3+
SonarCloud runs on every PR via GitHub Actions. The project key is
4+
`hypercerts-org_ePDS`. Use the public API to check results — no
5+
authentication required for public projects.
6+
7+
```bash
8+
# Quality gate status for a PR
9+
curl -s "https://sonarcloud.io/api/qualitygates/project_status?projectKey=hypercerts-org_ePDS&pullRequest=<N>" | python3 -m json.tool
10+
11+
# List new bugs on a PR
12+
curl -s "https://sonarcloud.io/api/issues/search?componentKeys=hypercerts-org_ePDS&pullRequest=<N>&types=BUG&resolved=false" | python3 -c "
13+
import sys,json
14+
for i in json.load(sys.stdin).get('issues',[]):
15+
print(f'{i[\"component\"].split(\":\")[-1]}:{i.get(\"line\",\"?\")} — {i[\"message\"]}')"
16+
17+
# List all new issues (bugs, code smells, vulnerabilities)
18+
curl -s "https://sonarcloud.io/api/issues/search?componentKeys=hypercerts-org_ePDS&pullRequest=<N>&resolved=false&ps=50" | python3 -c "
19+
import sys,json
20+
for i in json.load(sys.stdin).get('issues',[]):
21+
print(f'{i[\"type\"]:15} {i[\"component\"].split(\":\")[-1]}:{i.get(\"line\",\"?\")} — {i[\"message\"]}')"
22+
23+
# Duplication on new code
24+
curl -s "https://sonarcloud.io/api/measures/component?component=hypercerts-org_ePDS&pullRequest=<N>&metricKeys=new_duplicated_lines_density" | python3 -m json.tool
25+
26+
# Security hotspots
27+
curl -s "https://sonarcloud.io/api/hotspots/search?projectKey=hypercerts-org_ePDS&pullRequest=<N>" | python3 -c "
28+
import sys,json
29+
for h in json.load(sys.stdin).get('hotspots',[]):
30+
print(f'{h[\"component\"].split(\":\")[-1]}:{h.get(\"line\",\"?\")} — {h[\"message\"]}')"
31+
```
32+
33+
## Quality gate thresholds
34+
35+
On new code: reliability A (no bugs), security A, maintainability A,
36+
duplication < 3%, and 100% of security hotspots reviewed. Fix any
37+
issues before merging.
38+
39+
## NOSONAR annotations
40+
41+
When Sonar flags a line as a false positive (security hotspot, bug,
42+
or code smell that is intentional), add `// NOSONAR` at the end of
43+
the line with a brief reason:
44+
45+
```ts
46+
['http://', 'http://example.com/data.json', /only https/i], // NOSONAR — testing SSRF guard
47+
['private 10.x', 'https://10.0.0.1/path'], // NOSONAR — testing SSRF guard
48+
const html = `<script>${userCode}</script>` // NOSONAR — sanitised by escapeHtml() above
49+
```
50+
51+
Common cases: test data with private IPs or `http://` URLs,
52+
intentional use of patterns Sonar considers risky (inline scripts,
53+
hardcoded credentials in test fixtures, etc.).
54+
55+
**Do not use NOSONAR to suppress legitimate issues.** Every
56+
annotation must have a reason that explains why the flagged pattern
57+
is safe in this specific context. If you can't articulate why it's
58+
a false positive, fix the code instead.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# PR Review Comments API
2+
3+
To read and reply to review comments (CodeRabbit, etc.) on a PR:
4+
5+
```bash
6+
# List all review comments
7+
gh api repos/hypercerts-org/ePDS/pulls/<N>/comments --jq '.[] | {id, path: .path, line: .line}'
8+
9+
# Read a specific comment (NOTE: no PR number in this endpoint)
10+
gh api repos/hypercerts-org/ePDS/pulls/comments/<ID> --jq '.body'
11+
12+
# Reply to a comment
13+
gh api repos/hypercerts-org/ePDS/pulls/<N>/comments -F in_reply_to=<ID> -f body="..."
14+
```
15+
16+
**Important:** The individual comment endpoint is `/pulls/comments/<ID>` — it
17+
does NOT include the PR number. Using `/pulls/<N>/comments/<ID>` returns 404.
18+
19+
Check for and address unresolved review comments after every push.

.beads/issues.jsonl

Lines changed: 383 additions & 378 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'ePDS': minor
3+
---
4+
5+
Trusted apps can now style the sign-in and consent pages to match their own brand.
6+
7+
**Affects:** End users, Client app developers, Operators
8+
9+
**End users:** When signing in through an app that your ePDS operator has approved for branding, the login page, code entry page, handle picker, account recovery page, and consent page will display that app's colour scheme instead of the default look. The pages still work exactly the same way — only the visual appearance changes.
10+
11+
**Client app developers:** Add a `branding.css` field inside a `branding` object in your `client-metadata.json`. The CSS is injected as a `<style>` tag into every auth-service page and the PDS stock consent page (`/oauth/authorize`) when your `client_id` is listed in the operator's `PDS_OAUTH_TRUSTED_CLIENTS`. The CSS is size-capped at 32 KB (measured in escaped UTF-8 bytes) and sanitised to prevent `</style>` tag closure. The CSP `style-src` directive is updated with a SHA-256 hash of the injected CSS. Example metadata:
12+
13+
```json
14+
{
15+
"client_id": "https://app.example/client-metadata.json",
16+
"client_name": "My App",
17+
"branding": {
18+
"css": "body { background: #0f1b2d; color: #e2e8f0; } .btn-primary { background: #3b82f6; }"
19+
}
20+
}
21+
```
22+
23+
Untrusted clients (not in `PDS_OAUTH_TRUSTED_CLIENTS`) never get CSS injection, regardless of what their metadata contains.
24+
25+
**Operators:** CSS branding injection is controlled by the existing `PDS_OAUTH_TRUSTED_CLIENTS` env var on pds-core. No new env vars are required on pds-core or auth-service. The auth-service reads the same `PDS_OAUTH_TRUSTED_CLIENTS` list to decide whether to inject CSS on its pages (login, OTP, choose-handle, recovery). See `docs/configuration.md` for the full reference.
26+
27+
For the demo app, a new optional `EPDS_CLIENT_THEME` env var selects a named theme preset (e.g. `ocean`) that applies consistent styling to both the demo's own pages and the CSS served in its client metadata. When unset, the demo uses the default light theme with no branding CSS. See `packages/demo/.env.example` for details.

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ PDS_DPOP_SECRET=
8888
# openssl ecparam -name secp256k1 -genkey -noout | openssl ec -text -noout 2>/dev/null | grep priv -A 3 | tail -n +2 | tr -d '[:space:]:'
8989
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=
9090

91+
# Comma-separated OAuth client_id URLs trusted for CSS branding injection.
92+
# Set identically for both pds-core and auth-service.
93+
# PDS_OAUTH_TRUSTED_CLIENTS=
94+
9195
PDS_EMAIL_SMTP_URL=smtp://localhost:1025
9296
PDS_EMAIL_FROM_ADDRESS=noreply@pds.example
9397
PDS_BLOBSTORE_DISK_LOCATION=/data/blobs

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ pnpm vitest run packages/shared
6868
Tests live in `packages/<name>/src/__tests__/`. There is no per-package test
6969
script — all tests are run from the root via vitest.
7070

71+
### SonarCloud
72+
73+
SonarCloud runs on every PR. Check results and fix issues before
74+
merging. See [`.agents/reference/SonarCloud.md`](.agents/reference/SonarCloud.md)
75+
for API commands and quality gate thresholds.
76+
77+
### PR review comments
78+
79+
Check for and address unresolved review comments after every push.
80+
See [`.agents/reference/github-pr-comments.md`](.agents/reference/github-pr-comments.md)
81+
for API commands.
82+
7183
### End-to-end tests in CI
7284

7385
The e2e suite lives in `e2e/` and its feature files in `features/`. Normally
@@ -97,6 +109,10 @@ See [`e2e/README.md`](e2e/README.md#running-the-ci-e2e-job-against-a-railway-env
97109
for details (env-name formats, URL derivation, how to handle missing Railway
98110
domains).
99111

112+
The e2e suite uses two demo OAuth clients (trusted and untrusted) for
113+
trust-gated scenarios. See [`e2e/README.md`](e2e/README.md#two-demo-clients)
114+
for the full setup, tagging conventions, and step-definition patterns.
115+
100116
### Writing Tests
101117

102118
Before designing or writing new tests, read
@@ -271,6 +287,11 @@ import { AuthServiceContext } from './context.js'
271287

272288
## Security
273289

290+
- **Never hand-roll security code.** Use upstream or established libraries
291+
for SSRF protection, crypto, auth, input sanitization, etc. If a library
292+
integration has issues (e.g. test incompatibility), fix the integration —
293+
do not reimplement the security logic. If the integration truly can't
294+
work, stop and ask before proceeding with any alternative.
274295
- All epds-callback redirects must be HMAC-SHA256 signed using
275296
`signCallback()` / `verifyCallback()` from `@certified-app/shared`.
276297
- Use `timingSafeEqual()` for all secret/token comparisons.

docs/configuration.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,24 @@ marked `[shared]` in the per-package `.env.example` files.
5959

6060
### Trusted clients and consent skip
6161

62-
| Variable | Description |
63-
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64-
| `PDS_OAUTH_TRUSTED_CLIENTS` | Comma-separated list of OAuth `client_id` URLs. Trusted clients get relaxed consent handling from the upstream `@atproto/oauth-provider` — returning users who have already granted the requested scopes skip the consent screen. Has no effect on public clients (`token_endpoint_auth_method: "none"`). |
65-
| `PDS_SIGNUP_ALLOW_CONSENT_SKIP` | When `true` (or `1`), trusted clients whose metadata includes `"epds_skip_consent_on_signup": true` can skip the consent screen on initial sign-up. All three conditions must be met: this env var is truthy, the client is in `PDS_OAUTH_TRUSTED_CLIENTS`, and the client metadata opts in. |
62+
| Variable | Description |
63+
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64+
| `PDS_OAUTH_TRUSTED_CLIENTS` | Comma-separated list of OAuth `client_id` URLs. Trusted clients get relaxed consent handling and [CSS branding injection](#css-branding-injection). Has no effect on public clients (`token_endpoint_auth_method: "none"`). |
65+
| `PDS_SIGNUP_ALLOW_CONSENT_SKIP` | When `true` (or `1`), trusted clients whose metadata includes `"epds_skip_consent_on_signup": true` can skip the consent screen on initial sign-up. All three conditions must be met: this env var is truthy, the client is in `PDS_OAUTH_TRUSTED_CLIENTS`, and the client metadata opts in. |
66+
67+
### CSS branding injection
68+
69+
Trusted clients (listed in `PDS_OAUTH_TRUSTED_CLIENTS`) can provide
70+
custom CSS in their `client-metadata.json` under `branding.css`. When
71+
present, ePDS injects a `<style>` tag into:
72+
73+
- auth-service pages: login, OTP, choose-handle, recovery
74+
- PDS stock consent page (`/oauth/authorize`)
75+
76+
The CSS is size-capped at 32 KB and sanitised to prevent `</style>`
77+
tag closure. The CSP `style-src` directive is updated with a SHA-256
78+
hash of the injected CSS. Untrusted clients never get CSS injection
79+
regardless of what their metadata contains.
6680

6781
Optional PDS email variables:
6882

@@ -163,9 +177,11 @@ auth-service.
163177

164178
Optional:
165179

166-
| Variable | Description |
167-
| ------------------- | ---------------------------------------------------------------------------- |
168-
| `PLC_DIRECTORY_URL` | PLC directory for DID-to-handle resolution (default `https://plc.directory`) |
180+
| Variable | Description |
181+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182+
| `EPDS_CLIENT_THEME` | Named theme preset for the demo's own pages. When set, the demo renders with the preset's colour scheme and serves matching `branding.css` in its client metadata. Unset = default light theme. |
183+
| `EPDS_CLIENT_NAME` | Display name shown in the demo UI header (default `ePDS Demo`). |
184+
| `PLC_DIRECTORY_URL` | PLC directory for DID-to-handle resolution (default `https://plc.directory`). |
169185

170186
## Docker / Caddy
171187

e2e/.env.example

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ E2E_PDS_URL=https://pds.example.com
1010
# Auth service URL
1111
E2E_AUTH_URL=https://auth.example.com
1212

13-
# Demo app URL — the trusted demo client (listed in PDS_OAUTH_TRUSTED_CLIENTS).
14-
# All existing scenarios use this one by default.
13+
# Demo app URL — the trusted demo client (listed in pds-core's
14+
# PDS_OAUTH_TRUSTED_CLIENTS env var). All existing scenarios use this one
15+
# by default.
1516
E2E_DEMO_URL=https://demo.example.com
1617

1718
# URL of a second demo client that is NOT listed in PDS_OAUTH_TRUSTED_CLIENTS.
18-
# Required by the consent-screen scenarios that verify sign-up consent is only
19-
# skipped for trusted clients; those scenarios will fail if this is unset.
19+
# Needed by any scenario that exercises the trusted-vs-untrusted distinction
20+
# (negative trust tests, multi-client session reuse, etc.) — see
21+
# e2e/README.md#two-demo-clients for the full picture. Optional: when unset,
22+
# scenarios tagged @untrusted-client are automatically excluded by
23+
# e2e/cucumber.mjs and the rest of the suite runs normally.
2024
E2E_DEMO_UNTRUSTED_URL=https://demo-untrusted.example.com
2125

2226
# ── Mailpit ───────────────────────────────────────────────────────────────────
@@ -26,12 +30,6 @@ E2E_MAILPIT_URL=https://mailpit.example.com
2630
E2E_MAILPIT_USER=admin
2731
E2E_MAILPIT_PASS=
2832

29-
# ── Internal API ──────────────────────────────────────────────────────────────
30-
# Required for internal-api.feature scenarios. Leave empty to skip them.
31-
# Must match EPDS_INTERNAL_SECRET on the pds-core service.
32-
# Copy from the root .env file — it mirrors the Railway environment.
33-
E2E_EPDS_INTERNAL_SECRET=
34-
3533
# ── Optional ──────────────────────────────────────────────────────────────────
3634

3735
# Set to 'true' to run headless (no visible browser window). Default: false.

0 commit comments

Comments
 (0)