Skip to content

Commit f957889

Browse files
MajorTalclaude
andauthored
feat(auth): allowed_email_domains client surface + <SignIn> SSR sign-in errors (#418)
* feat(auth): add allowed_email_domains to auth settings (SDK + MCP + CLI) Public client surface for the hosted-auth Workspace-domain allowlist. Gateway enforcement lives in run402-private (feat/hosted-auth-domain-allowlist, issue #440); this lets operators restrict hosted Google sign-in to specific email domains, enforced server-side at token issuance. - SDK: allowed_email_domains on AuthSettings/AuthSettingsResult, added to AUTH_SETTINGS_FIELDS + validateAuthSettings (shape check only; the gateway owns normalization + domain-syntax validation) - MCP: allowed_email_domains on the auth_settings tool schema + rendered output - CLI: run402 auth settings --allowed-email-domains <csv|none> ('none' clears, mirroring the existing --preferred null sentinel since parseFlag drops "") - docs: llms-cli.txt command + R402_AUTH_DOMAIN_NOT_ALLOWED (403) catalog entry - tests: 3 SDK + 1 MCP cases; full suite green Refs #415 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(astro): <SignIn> renders hosted sign-in errors server-side (no JS) Companion to the gateway hosted-auth-signin-error-ssr change (run402-private#442): a failed hosted OAuth sign-in returns to the sign-in page with a server-readable ?r402_auth_error=<code>, and <SignIn> renders the message with zero client JS and zero consumer code. - sign-in-methods.ts: googleOauthHtml emits errorReturnTo on the start link; AUTH_ERROR_PARAM + messageForAuthError (specific copy for user-actionable codes, generic fallback otherwise) + .r402-auth-error styling. - SignIn.astro: reads ?r402_auth_error from Astro.url, renders a role="alert" block in the multi-method branch; emits its own URL as errorReturnTo. The password-only default branch is untouched, so the byte-identical default render contract (6.1/6.3) is preserved. - tests: alert render + absent + generic-fallback + builder/unit cases. Full astro suite 285/285; tsc build clean. - README: "Hosted sign-in errors render themselves" DX section. Refs #415 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cebdd42 commit f957889

10 files changed

Lines changed: 312 additions & 22 deletions

File tree

astro/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,19 @@ Every auth error carries a structured envelope with a `next_actions[].fix` paylo
312312

313313
Two pre-existing codes were enriched with `fix` payloads in this release (names unchanged): `R402_AUTH_CSRF_ORIGIN_MISMATCH` (submit from a Run402 component / same-origin form) and `R402_AUTH_PRERENDERED` (`export const prerender = false;`). At deploy time, `run402 doctor` statically detects a `createResponseFromTenantAssertion` call whose function lacks the `auth.sessionMint` capability and emits `R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING` with the exact spec edit — catching the footgun before it becomes a runtime 403.
314314

315+
### Hosted sign-in errors render themselves (`<SignIn>`)
316+
317+
A failed hosted OAuth sign-in — e.g. a Google account whose domain is not in the project's `allowed_email_domains` (set via `run402 auth settings --allowed-email-domains …`) — is returned to your sign-in page with a **server-readable** `?r402_auth_error=<code>` query param, and `<SignIn>` renders the message for you. Server-side, **no client JS, zero extra code**:
318+
319+
```astro
320+
---
321+
import { SignIn } from "@run402/astro/components";
322+
---
323+
<SignIn returnTo="/admin" methods={["google"]} />
324+
```
325+
326+
A `@gmail.com` user rejected from a Workspace-restricted admin tool lands back on this page with "This site is restricted to approved email domains. Sign in with your work account." rendered above the form. `<SignIn>` emits its own URL as the error-return target on the OAuth start link, so the round-trip is automatic — you never touch a query param or the URL hash. Known codes (`domain_not_allowed`, `account_exists_requires_link`, `identity_already_linked`) get specific copy; anything else (transient/infra) falls back to a generic "Sign-in could not be completed. Please try again." The block carries `role="alert"` and the `.r402-auth-error` class — restyle via your own CSS or the `--r402-error-fg` / `--r402-error-bg` / `--r402-error-border` custom properties. With no error param the render is byte-identical to before — nothing extra ships.
327+
315328
## `<Run402Picture>` — runtime CMS images
316329

317330
For images coming from a DB row at SSR time (the common CMS pattern), use `<Run402Picture asset={page.hero_asset}>`. The `asset` prop is the `AssetRef` JSONB that `r.assets.put()` returned at upload time — store the whole object, not just the URL, then render directly.

astro/src/components/SignIn.astro

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import {
5858
buildExtraMethodsHtml,
5959
EXTRA_METHOD_CSS,
6060
PASSKEY_SCRIPT,
61+
messageForAuthError,
62+
AUTH_ERROR_PARAM,
6163
} from "./sign-in-methods.js";
6264
6365
interface Props {
@@ -82,10 +84,22 @@ const defaultOnly = isDefaultOnly(methods);
8284
const resolved = normalizeMethods(methods);
8385
const hasPassword = includesPassword(resolved);
8486
const hasPasskey = resolved.includes("passkey");
87+
// hosted-auth-signin-error-ssr: a FAILED hosted OAuth sign-in returns the
88+
// visitor here with `?r402_auth_error=<code>` (a server-readable query param —
89+
// see the @run402/astro README). Render it server-side; no client JS. When the
90+
// param is absent nothing renders, and the default (password-only) branch never
91+
// touches it, so the byte-identical default contract (§6.1/§6.3) holds.
92+
const authErrorMessage = messageForAuthError(Astro.url.searchParams.get(AUTH_ERROR_PARAM));
93+
// Where the gateway returns a FAILED OAuth sign-in: this same sign-in page
94+
// (path only, so the error code can't accumulate across attempts). The gateway
95+
// validates it same-origin and delivers `?r402_auth_error=` there. Rides the
96+
// google start link below.
97+
const errorReturnTo = Astro.url.pathname;
98+
8599
// Extra (non-password) method markup, divider-interleaved. A leading divider
86100
// is added when the password form was rendered just above (so "or" sits
87101
// between password and the next method).
88-
const extraMethodsHtml = buildExtraMethodsHtml(resolved, returnTo, hasPassword);
102+
const extraMethodsHtml = buildExtraMethodsHtml(resolved, returnTo, hasPassword, errorReturnTo);
89103
90104
// §6.1/§6.3 byte-identical default: when `methods` is omitted or exactly
91105
// ["password"], the `defaultOnly` branch below renders the verbatim
@@ -132,6 +146,9 @@ const extraMethodsHtml = buildExtraMethodsHtml(resolved, returnTo, hasPassword);
132146
</form>
133147
) : (
134148
<div class={`r402-sign-in-methods ${className}`.trim()} data-r402-sign-in-methods>
149+
{authErrorMessage && (
150+
<div class="r402-auth-error" role="alert">{authErrorMessage}</div>
151+
)}
135152
{hasPassword && (
136153
<form
137154
method="POST"

astro/src/components/SignIn.test.ts

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
dividerHtml,
3939
PASSKEY_SCRIPT,
4040
DEFAULT_METHODS,
41+
messageForAuthError,
4142
} from "./sign-in-methods.js";
4243

4344
// ---------------------------------------------------------------------------
@@ -78,10 +79,15 @@ async function loadAstroComponent(astroFileUrl: URL): Promise<unknown> {
7879
async function render(
7980
astroFileUrl: URL,
8081
props: Record<string, unknown> = {},
82+
requestUrl?: string,
8183
): Promise<string> {
8284
const Component = await loadAstroComponent(astroFileUrl);
8385
const container = await AstroContainer.create();
84-
return container.renderToString(Component as never, { props });
86+
const opts: Record<string, unknown> = { props };
87+
// Drives `Astro.url` so errorReturnTo emission + the ?r402_auth_error read are
88+
// deterministic in tests (hosted-auth-signin-error-ssr).
89+
if (requestUrl) opts.request = new Request(requestUrl);
90+
return container.renderToString(Component as never, opts as never);
8591
}
8692

8793
/** Astro derives the `astro-xxxxxxxx` scope hash from component source and
@@ -193,12 +199,18 @@ describe("SignIn.astro — methods rendering", () => {
193199
assert.doesNotMatch(dom, /<script/);
194200
});
195201

196-
it("(c) methods=['google'] renders an anchor href containing /auth/sign-in/oauth/google/start", async () => {
197-
const html = await render(SIGNIN_URL, { returnTo: "/portal", methods: ["google"] });
202+
it("(c) methods=['google'] renders an anchor href containing /auth/sign-in/oauth/google/start (+ errorReturnTo)", async () => {
203+
const html = await render(
204+
SIGNIN_URL,
205+
{ returnTo: "/portal", methods: ["google"] },
206+
"https://app.example/auth/sign-in",
207+
);
198208
const dom = markup(html);
209+
// The start link carries returnTo AND errorReturnTo (this sign-in page), so
210+
// a domain-rejected sign-in is delivered back here server-readably.
199211
assert.match(
200212
dom,
201-
/<a class="r402-oauth r402-oauth-google" href="\/auth\/sign-in\/oauth\/google\/start\?returnTo=%2Fportal">Continue with Google<\/a>/,
213+
/<a class="r402-oauth r402-oauth-google" href="\/auth\/sign-in\/oauth\/google\/start\?returnTo=%2Fportal&amp;errorReturnTo=%2Fauth%2Fsign-in">Continue with Google<\/a>/,
202214
);
203215
assert.doesNotMatch(dom, /action="\/auth\/sign-in"/);
204216
assert.doesNotMatch(dom, /<script/);
@@ -262,14 +274,56 @@ describe("SignIn.astro — methods rendering", () => {
262274
});
263275

264276
it("returnTo is HTML-attribute-escaped where interpolated", async () => {
265-
const html = await render(SIGNIN_URL, {
266-
returnTo: '/a&b"c',
267-
methods: ["magic_link", "google"],
268-
});
277+
const html = await render(
278+
SIGNIN_URL,
279+
{ returnTo: '/a&b"c', methods: ["magic_link", "google"] },
280+
"https://app.example/login",
281+
);
269282
// magic-link hidden input value escaped
270283
assert.match(html, /name="returnTo" value="\/a&amp;b&quot;c"/);
271-
// google href: encodeURIComponent then attribute-escape (& → &amp;)
272-
assert.match(html, /href="\/auth\/sign-in\/oauth\/google\/start\?returnTo=%2Fa%26b%22c"/);
284+
// google href: encodeURIComponent then attribute-escape (& → &amp;), plus the
285+
// errorReturnTo (this page) appended as a second query param.
286+
assert.match(html, /href="\/auth\/sign-in\/oauth\/google\/start\?returnTo=%2Fa%26b%22c&amp;errorReturnTo=%2Flogin"/);
287+
});
288+
289+
// hosted-auth-signin-error-ssr: a failed hosted OAuth sign-in returns here with
290+
// ?r402_auth_error=<code>; <SignIn> renders it server-side, no client JS.
291+
it("renders an auth-error alert from ?r402_auth_error (server-side, no JS)", async () => {
292+
const html = await render(
293+
SIGNIN_URL,
294+
{ returnTo: "/admin", methods: ["google"] },
295+
"https://app.example/auth/sign-in?r402_auth_error=domain_not_allowed",
296+
);
297+
const dom = stripScope(markup(html));
298+
assert.match(
299+
dom,
300+
/<div class="r402-auth-error" role="alert">This site is restricted to approved email domains[^<]*<\/div>/,
301+
);
302+
// The message is in the SSR HTML — no script required to display it.
303+
assert.doesNotMatch(dom, /<script/);
304+
});
305+
306+
it("renders NO auth-error alert when ?r402_auth_error is absent", async () => {
307+
const html = await render(
308+
SIGNIN_URL,
309+
{ returnTo: "/admin", methods: ["google"] },
310+
"https://app.example/auth/sign-in",
311+
);
312+
// The alert DIV is absent from the markup (the .r402-auth-error CSS rule
313+
// still ships in the stylesheet, hence checking markup() not the full html).
314+
assert.doesNotMatch(markup(html), /r402-auth-error/);
315+
});
316+
317+
it("an unknown auth-error code falls back to the generic message", async () => {
318+
const html = await render(
319+
SIGNIN_URL,
320+
{ returnTo: "/admin", methods: ["google"] },
321+
"https://app.example/auth/sign-in?r402_auth_error=token_exchange_failed",
322+
);
323+
assert.match(
324+
stripScope(markup(html)),
325+
/<div class="r402-auth-error" role="alert">Sign-in could not be completed[^<]*<\/div>/,
326+
);
273327
});
274328
});
275329

@@ -338,6 +392,13 @@ describe("sign-in-methods — markup builders", () => {
338392
);
339393
});
340394

395+
it("googleOauthHtml appends errorReturnTo when provided (hosted-auth-signin-error-ssr)", () => {
396+
assert.equal(
397+
googleOauthHtml("/portal", "/auth/sign-in"),
398+
'<a class="r402-oauth r402-oauth-google" href="/auth/sign-in/oauth/google/start?returnTo=%2Fportal&amp;errorReturnTo=%2Fauth%2Fsign-in">Continue with Google</a>',
399+
);
400+
});
401+
341402
it("passkeyButtonHtml emits the data hooks + escaped returnTo", () => {
342403
assert.equal(
343404
passkeyButtonHtml('/x"y'),
@@ -391,3 +452,24 @@ describe("sign-in-methods — markup builders", () => {
391452
assert.match(PASSKEY_SCRIPT, /catch \(_err\)/);
392453
});
393454
});
455+
456+
describe("sign-in-methods — messageForAuthError (hosted-auth-signin-error-ssr)", () => {
457+
it("returns null for no/empty code", () => {
458+
assert.equal(messageForAuthError(null), null);
459+
assert.equal(messageForAuthError(undefined), null);
460+
assert.equal(messageForAuthError(""), null);
461+
});
462+
463+
it("returns specific copy for user-actionable codes", () => {
464+
assert.match(messageForAuthError("domain_not_allowed")!, /approved email domains/);
465+
assert.match(messageForAuthError("account_exists_requires_link")!, /already exists/);
466+
assert.match(messageForAuthError("identity_already_linked")!, /already linked/);
467+
});
468+
469+
it("falls back to a generic message for infra/unknown codes", () => {
470+
const generic = /could not be completed/;
471+
assert.match(messageForAuthError("token_exchange_failed")!, generic);
472+
assert.match(messageForAuthError("id_token_invalid")!, generic);
473+
assert.match(messageForAuthError("totally_unknown_code")!, generic);
474+
});
475+
});

astro/src/components/sign-in-methods.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,17 @@ export function magicLinkFormHtml(returnTo: string): string {
101101
);
102102
}
103103

104-
/** `google` — a no-JS link/button to the hosted OAuth start route (GET). */
105-
export function googleOauthHtml(returnTo: string): string {
104+
/** `google` — a no-JS link/button to the hosted OAuth start route (GET).
105+
* `errorReturnTo` (hosted-auth-signin-error-ssr): the same-origin sign-in-page
106+
* URL the gateway returns to on a FAILED sign-in, with `?r402_auth_error=<code>`.
107+
* When provided it rides the start link so the error round-trip is zero-config. */
108+
export function googleOauthHtml(returnTo: string, errorReturnTo?: string): string {
106109
// The href is built with encodeURIComponent at render time; the resulting
107110
// string is then attribute-escaped so `&` becomes `&amp;` in the markup.
108-
const href = escapeAttr(`/auth/sign-in/oauth/google/start?returnTo=${encodeURIComponent(returnTo)}`);
111+
const qs =
112+
`returnTo=${encodeURIComponent(returnTo)}` +
113+
(errorReturnTo ? `&errorReturnTo=${encodeURIComponent(errorReturnTo)}` : "");
114+
const href = escapeAttr(`/auth/sign-in/oauth/google/start?${qs}`);
109115
return `<a class="r402-oauth r402-oauth-google" href="${href}">Continue with Google</a>`;
110116
}
111117

@@ -127,15 +133,15 @@ export function passkeyButtonHtml(returnTo: string): string {
127133
* between every adjacent pair across the full ordered method list (including
128134
* the password form, which the component owns).
129135
*/
130-
export function buildMethodBlocks(methods: SignInMethod[], returnTo: string): string[] {
136+
export function buildMethodBlocks(methods: SignInMethod[], returnTo: string, errorReturnTo?: string): string[] {
131137
const out: string[] = [];
132138
for (const m of methods) {
133139
switch (m) {
134140
case "magic_link":
135141
out.push(magicLinkFormHtml(returnTo));
136142
break;
137143
case "google":
138-
out.push(googleOauthHtml(returnTo));
144+
out.push(googleOauthHtml(returnTo, errorReturnTo));
139145
break;
140146
case "passkey":
141147
out.push(passkeyButtonHtml(returnTo));
@@ -161,8 +167,9 @@ export function buildExtraMethodsHtml(
161167
methods: SignInMethod[],
162168
returnTo: string,
163169
passwordIsFirst: boolean,
170+
errorReturnTo?: string,
164171
): string {
165-
const blocks = buildMethodBlocks(methods, returnTo);
172+
const blocks = buildMethodBlocks(methods, returnTo, errorReturnTo);
166173
if (blocks.length === 0) return "";
167174
const divider = dividerHtml();
168175
const joined = blocks.join(divider);
@@ -303,4 +310,47 @@ export const EXTRA_METHOD_CSS = `
303310
.r402-passkey[hidden] {
304311
display: none;
305312
}
313+
.r402-auth-error {
314+
padding: 0.625rem 0.75rem;
315+
font-size: 0.875rem;
316+
color: var(--r402-error-fg, #8a1c1c);
317+
background: var(--r402-error-bg, #fdecea);
318+
border: 1px solid var(--r402-error-border, #f5c2c0);
319+
border-radius: var(--r402-radius, 0.375rem);
320+
}
306321
`;
322+
323+
/**
324+
* hosted-auth-signin-error-ssr: surfacing hosted sign-in errors in <SignIn>.
325+
*
326+
* On a FAILED hosted OAuth sign-in the gateway returns the visitor to the
327+
* sign-in page with `?<AUTH_ERROR_PARAM>=<code>` — a SERVER-READABLE query param
328+
* (not the legacy URL hash), so the no-JS SSR component renders a message with
329+
* zero consumer code. The codes mirror the gateway callback's recoverable
330+
* reasons (see the gateway's `redirectError`).
331+
*/
332+
export const AUTH_ERROR_PARAM = "r402_auth_error";
333+
334+
/** Specific, user-actionable copy for codes a visitor can act on. Infra /
335+
* transient codes intentionally fall through to the generic message. */
336+
export const AUTH_ERROR_MESSAGES: Record<string, string> = {
337+
domain_not_allowed:
338+
"This site is restricted to approved email domains. Sign in with your work account.",
339+
account_exists_requires_link:
340+
"An account with this email already exists. Sign in with your original method, then link Google from your account settings.",
341+
identity_already_linked:
342+
"This Google account is already linked to a different account.",
343+
};
344+
345+
const GENERIC_AUTH_ERROR = "Sign-in could not be completed. Please try again.";
346+
347+
/**
348+
* Map a delivered `r402_auth_error` code to user-facing copy. Returns null for
349+
* no/empty code (nothing to show). Known user-actionable codes get specific
350+
* copy; anything else (incl. infra codes like `token_exchange_failed`) gets a
351+
* safe generic message rather than leaking a raw code or rendering blank.
352+
*/
353+
export function messageForAuthError(code: string | null | undefined): string | null {
354+
if (!code) return null;
355+
return AUTH_ERROR_MESSAGES[code] ?? GENERIC_AUTH_ERROR;
356+
}

cli/lib/auth.mjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Subcommands:
2424
set-password --token <bearer> --new <password> [--current <password>] [--project <id>]
2525
Change, reset, or set a user's password. Requires the user's access_token.
2626
27-
settings [--allow-password-set <true|false>] [--preferred <method|null>] [--public-signup <policy>] [--require-admin-passkey <true|false>] [--project <id>]
27+
settings [--allow-password-set <true|false>] [--preferred <method|null>] [--public-signup <policy>] [--require-admin-passkey <true|false>] [--allowed-email-domains <csv|none>] [--project <id>]
2828
Update project auth settings (requires service_key).
2929
3030
passkey-register-options --token <bearer> --app-origin <origin> [--project <id>]
@@ -147,14 +147,19 @@ Options:
147147
--preferred <method|null> password, magic_link, oauth_google, passkey, or null
148148
--public-signup <policy> open, known_email, or invite_only
149149
--require-admin-passkey <true|false> Require passkey auth for project_admin sessions
150+
--allowed-email-domains <csv|none> Restrict hosted Google sign-in to these domains; 'none' clears (unrestricted)
150151
--project <id> Project ID (defaults to active project)
151152
152153
Notes:
153154
Requires the project's service_key (admin-level).
155+
--allowed-email-domains is comma-separated (e.g. kychee.com,example.com); pass 'none' to clear.
156+
Domains are normalized + validated server-side; hosted Google sign-in from any
157+
other domain is rejected at token issuance (R402_AUTH_DOMAIN_NOT_ALLOWED).
154158
155159
Examples:
156160
run402 auth settings --allow-password-set true
157161
run402 auth settings --preferred passkey --require-admin-passkey true
162+
run402 auth settings --allowed-email-domains kychee.com,example.com
158163
`,
159164
"passkey-register-options": `run402 auth passkey-register-options — Create passkey registration options
160165
@@ -253,8 +258,8 @@ const AUTH_FLAGS = {
253258
values: ["--token", "--new", "--current", "--project"],
254259
},
255260
settings: {
256-
known: ["--allow-password-set", "--preferred", "--public-signup", "--require-admin-passkey", "--project", "--help", "-h"],
257-
values: ["--allow-password-set", "--preferred", "--public-signup", "--require-admin-passkey", "--project"],
261+
known: ["--allow-password-set", "--preferred", "--public-signup", "--require-admin-passkey", "--allowed-email-domains", "--project", "--help", "-h"],
262+
values: ["--allow-password-set", "--preferred", "--public-signup", "--require-admin-passkey", "--allowed-email-domains", "--project"],
258263
},
259264
"passkey-register-options": {
260265
known: ["--token", "--app-origin", "--project", "--help", "-h"],
@@ -460,12 +465,14 @@ async function settings(args) {
460465
const requireAdminPasskey = parseOptionalBool(args, "--require-admin-passkey");
461466
const preferredRaw = parseFlag(args, "--preferred");
462467
const publicSignup = parseFlag(args, "--public-signup");
468+
const domainsRaw = parseFlag(args, "--allowed-email-domains");
463469

464470
if (
465471
allow === undefined &&
466472
requireAdminPasskey === undefined &&
467473
preferredRaw === null &&
468-
publicSignup === null
474+
publicSignup === null &&
475+
domainsRaw === null
469476
) {
470477
fail({
471478
code: "BAD_USAGE",
@@ -488,12 +495,23 @@ async function settings(args) {
488495
});
489496
}
490497

498+
// --allowed-email-domains: comma-separated list; the literal `none` clears the
499+
// restriction (→ []); omitted preserves. Server normalizes + validates each entry.
500+
let allowedEmailDomains;
501+
if (domainsRaw !== null) {
502+
allowedEmailDomains =
503+
domainsRaw.trim().toLowerCase() === "none"
504+
? []
505+
: domainsRaw.split(",").map((d) => d.trim()).filter((d) => d.length > 0);
506+
}
507+
491508
try {
492509
const patch = {
493510
allow_password_set: allow,
494511
preferred_sign_in_method: preferredRaw === "null" ? null : preferredRaw ?? undefined,
495512
public_signup: publicSignup ?? undefined,
496513
require_passkey_for_project_admin: requireAdminPasskey,
514+
allowed_email_domains: allowedEmailDomains,
497515
};
498516
const data = await getSdk().auth.settings(projectId, patch);
499517
console.log(JSON.stringify({ ...patch, ...data }));

0 commit comments

Comments
 (0)