diff --git a/console/app/components/SignInClientForm.vue b/console/app/components/SignInClientForm.vue new file mode 100644 index 0000000..81a2ccc --- /dev/null +++ b/console/app/components/SignInClientForm.vue @@ -0,0 +1,329 @@ + + + diff --git a/console/app/components/SignInClientSecret.vue b/console/app/components/SignInClientSecret.vue new file mode 100644 index 0000000..27a38e4 --- /dev/null +++ b/console/app/components/SignInClientSecret.vue @@ -0,0 +1,226 @@ + + + diff --git a/console/app/middleware/auth.global.ts b/console/app/middleware/auth.global.ts index cd830a2..ae65970 100644 --- a/console/app/middleware/auth.global.ts +++ b/console/app/middleware/auth.global.ts @@ -10,10 +10,30 @@ * call checks the session itself -- what this does is keep a signed-out * person from watching a dashboard fail to load, one panel at a time. */ +import { isGuildSignInPath } from '~/utils/oauthClient' + const PUBLIC_ROUTES = new Set(['/sign-in']) +/** + * Whether this page exists before a session does. + * + * Two answers, and the second one is a family rather than a path. A guild's + * sign-in link is `/g/{slug}/sign-in` and the slug is whatever that guild + * registered, so the allowlist cannot enumerate them — and it must not try + * to, in either direction. It does not ask which slugs are registered, + * because nothing here may; and it does not ask whether the slug is even + * spelled like one, because a middleware that sent a malformed name to the + * ordinary sign-in page and a registered name to the guild page would be a + * one-request oracle for which organisations use this service, which is the + * exact disclosure §2.2 exists to prevent. Every slug reaches the same + * page, which hands them all to an endpoint that answers them all alike. + */ +function isPublic(path: string): boolean { + return PUBLIC_ROUTES.has(path) || isGuildSignInPath(path) +} + export default defineNuxtRouteMiddleware(async (to) => { - if (PUBLIC_ROUTES.has(to.path)) return + if (isPublic(to.path)) return // Only a 401 sends somebody to the sign-in page. An API that is down or // erroring is a different failure with a different remedy, and dressing diff --git a/console/app/pages/admin/sign-in-link.vue b/console/app/pages/admin/sign-in-link.vue new file mode 100644 index 0000000..1a167f5 --- /dev/null +++ b/console/app/pages/admin/sign-in-link.vue @@ -0,0 +1,633 @@ + + + diff --git a/console/app/pages/g/[slug]/sign-in.vue b/console/app/pages/g/[slug]/sign-in.vue new file mode 100644 index 0000000..0cc45da --- /dev/null +++ b/console/app/pages/g/[slug]/sign-in.vue @@ -0,0 +1,108 @@ + + + diff --git a/console/app/utils/navigation.ts b/console/app/utils/navigation.ts index 5c208ff..375b074 100644 --- a/console/app/utils/navigation.ts +++ b/console/app/utils/navigation.ts @@ -104,6 +104,20 @@ export const ADMIN_VIEW: NavSection = { icon: 'M18 16.08a2.9 2.9 0 0 0-1.96.77L8.91 12.7c.06-.23.09-.46.09-.7s-.03-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 1 0-3-3c0 .24.04.47.09.7L8.04 9.81A2.98 2.98 0 0 0 6 9a3 3 0 0 0 0 6c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65a2.92 2.92 0 1 0 2.92-2.92Z', adminOnly: true, }, + { + to: '/admin/sign-in-link', + labelKey: 'nav.signInLink', + // A key, because that is what this page configures: the credential a + // guild's own identity provider issues, and therefore who this + // console lets in at all. Deliberately not a padlock — a padlock says + // "this is protected", and this page is where somebody decides who is + // let through rather than a statement that somebody is being kept + // out. The third configuration entry, so that the three pages that + // configure a guild sit together and this one sits next to Bot + // Settings, where `admin_role_id` decides who can reach it. + icon: 'M12.65 10A6 6 0 0 0 7 6a6 6 0 1 0 0 12 6 6 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65ZM7 14a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z', + adminOnly: true, + }, { // Renamed from `/admin/user-settings`, which read as "settings for // users" and is in fact a roster of other people's consent. The old diff --git a/console/app/utils/oauthClient.ts b/console/app/utils/oauthClient.ts new file mode 100644 index 0000000..2d26771 --- /dev/null +++ b/console/app/utils/oauthClient.ts @@ -0,0 +1,683 @@ +/** + * A guild's own sign-in link, as far as the console is allowed to decide it. + * + * `#147` gave a guild the ability to sign its people in against its own + * Outline rather than against the one this deployment is configured with, + * and left it reachable only from `curl`: the `guild_oauth_client` rows + * exist, five routes write them, and nothing in the browser ever has. This + * module holds every decision the page that does makes, so that the page + * itself is layout and request plumbing — the rule the rest of `app/utils` + * follows, and the reason those rules can be tested without mounting + * anything. + * + * Five things here are worth arguing for rather than reading past. + * + * **1. There is no shape here that can carry a client secret.** + * {@link GuildOAuthClient} has `hasSecret` and nowhere to put a value, + * because the API's read model has `has_secret` and nowhere to put a value. + * {@link ClientDraft} — what the registration form edits and submits — has + * no credential field at all, so "saving a change of base URL wiped the + * secret" is not a bug this console avoids, it is a request this console + * cannot construct. That is `~/utils/exportTargets`' argument, made again + * because the failure it prevents is the same one and the stakes here are + * higher: this credential decides who gets a session at all. + * + * **2. This console checks the *shape* of a slug and never its + * availability.** `routes_oauth` answers 400 to a slug that is not spelled + * like one and 409 to a slug that is spelled correctly and is not this + * guild's to have — and it gives that same 409 whether the name is held by + * another guild or reserved by the deployment, *deliberately*, so that + * which of the two it was cannot be read off the reply. A console that + * carried its own copy of `RESERVED_SLUGS` would answer "that name is + * reserved" without a request, which re-introduces exactly the distinction + * the API collapsed, in the one place an administrator reads. So + * {@link slugProblem} mirrors `has_slug_shape` and stops there, and 409 is + * rendered as the API's own one answer: pick a different name. **Nothing + * anywhere in this console asks whether a slug is free.** + * + * **3. Nothing is normalised.** `Acme` is refused rather than lowercased, + * matching `is_valid_slug`, and for its reason: a slug quietly rewritten on + * the way into the table is a slug the administrator does not recognise in + * the link they handed out. The same goes for the two URLs — they are + * tested and never rewritten, which is why {@link isProviderUrl} refuses a + * value with whitespace around it instead of trimming it. + * + * **4. A registration without a secret is a link that answers exactly as an + * unknown one.** That is not a defect to be hidden behind a spinner; it is + * the state an administrator is in between step 2 and step 3 of + * §6.2.12, and {@link linkState} says so out loud. A page that drew a + * half-configured link as working would have somebody hand it out. + * + * **5. The guild's own client governs the console sign-in and nothing + * else.** The Discord account-link flow stays on the environment-configured + * client permanently: `api` holds the master key and `link` does not, and + * `charts/sturnus/templates/_helpers.tpl` refuses to render it onto that + * component at all. {@link SCOPE_NOTE_KEY} is that sentence, kept here + * beside the rest of the contract rather than loose in a template, so that + * an interface which implied otherwise would have to delete an argument to + * do it. + * + * Every sentence here is a translation key or a {@link Message}, never + * prose: a pure function returns data. See `i18n/README.md`. + * + * **Why half of these names begin with `client`.** Nuxt auto-imports every + * export under `app/utils`, so two modules exporting one name is not a + * matter of taste — the build picks one of them and warns, and whichever it + * picks is what any file that did not import explicitly gets. This module + * and `~/utils/exportTargets` answer very similar questions about two very + * different credentials, so the overlap was total: `secretState`, + * `draftBody`, `draftProblems` and six more. They are prefixed here rather + * than there because the older module's name is the one already written + * into pages, and because "the draft body of *what*" is a question these + * names should have been answering anyway. + */ +import type { Message } from './message' + +/* -------------------------------------------------------------------- */ +/* What the API sends */ +/* -------------------------------------------------------------------- */ + +/** + * One guild's console sign-in client, as anything outside may see it. + * + * `guildId` is a string because a Discord snowflake exceeds JavaScript's + * safe integer range, where a JSON number silently loses its last digits + * and produces an id that looks right and names nobody. The API sends it as + * a string for that reason; this keeps it one. + * + * `redirectUri` is `null` for a guild using this deployment's own callback, + * which is what nearly every guild wants. Present-and-null rather than + * absent, so "the default" and "an API that does not send this field" stay + * distinguishable. + * + * **There is nowhere here to put the secret.** `hasSecret` is the whole of + * what any response says about one. + */ +export interface GuildOAuthClient { + guildId: string + slug: string + provider: string + baseUrl: string + clientId: string + redirectUri: string | null + /** That a credential is stored. Never the credential. */ + hasSecret: boolean + /** ISO-8601, or `null` when the API sent something unreadable. */ + createdAt: string | null + updatedAt: string | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asText(value: unknown): string | null { + if (value === null || value === undefined) return null + return typeof value === 'string' ? value : String(value) +} + +/** + * The registration in a `GET`, `PUT` or secret-write response, or `null`. + * + * `null` for a payload with no slug in it, because the slug is the whole + * point of the row: a registration this console rendered with a blank name + * would be a sign-in link nobody could read off the screen, and every + * remedy for it — re-register, remove — is reachable anyway from the state + * where there is no registration at all. + * + * Tolerant of everything else, the way `parseTarget` is. A registration + * whose base URL came back empty is still a registration, and drawing it is + * how somebody finds out it needs fixing. + */ +export function parseClient(payload: unknown): GuildOAuthClient | null { + if (!isRecord(payload)) return null + const held = isRecord(payload.oauth_client) ? payload.oauth_client : payload + const slug = asText(held.slug) + if (slug === null || slug === '') return null + return { + guildId: asText(payload.guild_id) ?? asText(held.guild_id) ?? '', + slug, + provider: asText(held.provider) ?? '', + baseUrl: asText(held.base_url) ?? '', + clientId: asText(held.client_id) ?? '', + // Absent and null are one answer here, and it is the right one: both + // mean "this deployment's own callback", which is what the field means + // when the API omits it and what it means when the API sends null. + redirectUri: asText(held.redirect_uri), + // Absent is false rather than true. A registration this console cannot + // tell the state of is better drawn as not yet live — the reader then + // supplies a secret and learns the truth — than drawn as a working link + // that answers 404 to everybody who follows it. + hasSecret: held.has_secret === true, + createdAt: asText(held.created_at), + updatedAt: asText(held.updated_at), + } +} + +/* -------------------------------------------------------------------- */ +/* The one provider this deployment can exchange with */ +/* -------------------------------------------------------------------- */ + +/** + * The provider `routes_oauth` accepts, which is the one `console.auth` can + * complete a code exchange against. + * + * There is no picker over this and there should not be: a dropdown with one + * row is a control that asks a question with one answer. `_registration` + * refuses anything else with a 400 rather than storing it, and it says why + * — a registration against a provider nothing here can exchange with is a + * guild whose link is permanently and silently broken. + */ +export const PROVIDER_OUTLINE = 'outline' + +/* -------------------------------------------------------------------- */ +/* What a slug is */ +/* -------------------------------------------------------------------- */ + +/** Short enough to be typed and read back over a chat message, long enough + * to name an organisation. `MIN_SLUG_LENGTH` in `domain/oauth_clients.py`. */ +export const MIN_SLUG_LENGTH = 3 +export const MAX_SLUG_LENGTH = 32 + +/** + * Lowercase, hyphen-separated words, beginning with a letter. + * + * The leading letter is the rule that costs the most and buys the most: a + * Discord snowflake is digits, and `/g/1289374650912837465/sign-in` and a + * guild id in a path are the same string to whoever is reading the link. + * Requiring a letter first makes a slug and an id unconfusable rather than + * merely unlikely to be confused. + * + * Anchored at both ends with no `m` flag, which in JavaScript means the + * whole string — the equivalent of Python's `fullmatch`. + */ +const SLUG_SHAPE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ + +/** + * Why this cannot be a sign-in name, or `null`. + * + * A second copy of a rule the API enforces, and — like `acceptsTarget` next + * door — a courtesy rather than a control. `apiError.sanitiseFetchError` + * keeps nothing of a refusal but its status, on purpose, so a rule only the + * API checks is a rule whose reason the reader never sees: they get a bare + * 400 where they wanted to be told they had typed a capital letter. + * + * Three different complaints rather than one, because they are three + * different typing mistakes and "the sign-in name is invalid" tells nobody + * which of them they made. + * + * **Shape only.** Whether the name is free is not asked here and is not + * asked anywhere — see the note at the top of this module. + */ +export function slugProblem(slug: string): Message | null { + if (slug === '') return { key: 'admin.signInLink.slugEmpty' } + if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + return { + key: 'admin.signInLink.slugLength', + params: { min: String(MIN_SLUG_LENGTH), max: String(MAX_SLUG_LENGTH) }, + } + } + if (!SLUG_SHAPE.test(slug)) return { key: 'admin.signInLink.slugShape' } + return null +} + +/* -------------------------------------------------------------------- */ +/* What an address is */ +/* -------------------------------------------------------------------- */ + +/** `_MAX_URL` and `_MAX_CLIENT_ID` in `routes_oauth`. Not a claim about + * what any provider issues — they are what keeps a `Text` column from + * being a place to store a megabyte through an authenticated endpoint. */ +export const MAX_URL_LENGTH = 2048 +export const MAX_CLIENT_ID_LENGTH = 512 +/** `_MAX_SECRET`. The one bound this console applies to a value it must + * never otherwise look at. */ +export const MAX_SECRET_LENGTH = 1024 + +/** + * Whether this may be a guild's identity-provider base URL or its callback. + * + * `is_provider_url` in `domain/oauth_clients.py`, held to the same four + * rules and for the same reasons. Both of these are addresses an + * administrator of one guild chooses and other people's browsers follow. + * + * - **`https` only.** The authorization code, and the whole consent step, + * travel over it. + * - **No userinfo.** `https://console.example@evil.example/` is a valid URL + * naming `evil.example` that reads to a human as the first host. It is + * the one form where refusing to parse is the difference between what an + * administrator reviewing the value sees and what a browser does — which + * is exactly why the check is worth having on the screen where the value + * is typed, and not only in the API that stores it. + * - **No query and no fragment.** `authorize_url` builds its own query + * string, and a fragment never reaches a server at all. + * + * A path is allowed: an Outline behind `https://wiki.example/outline` is an + * ordinary deployment. + * + * Whitespace around the value is refused rather than trimmed, because + * nothing on this path normalises: the value stored is the value typed. + */ +export function isProviderUrl(value: string): boolean { + if (value !== value.trim() || /\s/.test(value)) return false + let parsed: URL + try { + parsed = new URL(value) + } catch { + return false + } + return ( + parsed.protocol === 'https:' + && parsed.hostname !== '' + && parsed.username === '' + && parsed.password === '' + && parsed.search === '' + && parsed.hash === '' + ) +} + +/* -------------------------------------------------------------------- */ +/* Where the link goes, and whether it works */ +/* -------------------------------------------------------------------- */ + +/** + * The path a guild hands out. + * + * `/g/{slug}/sign-in`, the shape `domain/oauth_clients.py` names and the + * one this console now actually serves. It is a path segment rather than + * `?guild=` in a link an administrator distributes for one reason worth + * stating: the API endpoint underneath it, `/api/auth/login?guild=…`, is a + * redirect with no page — somebody who follows it while the registration is + * half-finished meets a JSON body, and somebody who follows it while it is + * finished never sees this deployment at all. A page in between is where + * the product gets to say whose sign-in this is. + */ +export function signInPath(slug: string): string { + return `/g/${encodeURIComponent(slug)}/sign-in` +} + +/** + * Whether a path is a guild's sign-in page. + * + * Kept beside {@link signInPath} rather than in the middleware that asks + * the question, because a builder and a matcher for one route shape in two + * files are two shapes waiting to disagree — and the way they would + * disagree here is that a link an administrator handed out stops being + * recognised as public and bounces its followers to a sign-in page they + * have no way to use. + * + * Deliberately looser than {@link slugProblem}: **any** segment counts. + * A middleware that only let well-formed slugs through would send `/g/ACME/ + * sign-in` to the ordinary sign-in page and a registered one to the guild + * page, and the difference between those two screens is the difference the + * whole design refuses to expose. Every well-formed name and every + * malformed one reach the same page, which then hands them all to the same + * endpoint, which answers all of them with the same 404. + */ +export function isGuildSignInPath(path: string): boolean { + return /^\/g\/[^/]+\/sign-in\/?$/.test(path) +} + +/** The link in full, for the box an administrator copies out of. `origin` + * is the console's own, which `useRequestURL` answers identically on the + * server and in the browser — so the value does not change under the + * reader on hydration. */ +export function signInUrl(origin: string, slug: string): string { + return `${origin.replace(/\/+$/, '')}${signInPath(slug)}` +} + +/** + * Where the guild sign-in page sends the browser. + * + * A plain navigation and never a fetch: the OAuth flow leaves this origin + * and comes back, and an XHR cannot follow it. The same reasoning + * `pages/sign-in.vue` gives for its own anchor. + */ +export function loginUrl(slug: string): string { + return `/api/auth/login?guild=${encodeURIComponent(slug)}` +} + +/** + * What state a guild's link is in, in three words and a sentence. + * + * The middle state is the one this exists for. Between registering a client + * and supplying its secret, the link is real, published, and answers + * **exactly** as a name nobody has ever registered — the same 404, the same + * body — because telling those two apart is precisely what §2.2 refuses to + * let anybody do. That is the design working, not a fault, and an interface + * that drew it as "configured" would have somebody hand the link out and + * spend an afternoon on why their colleagues cannot sign in. + */ +export type LinkTone = 'absent' | 'incomplete' | 'live' + +export interface LinkState { + tone: LinkTone + headingKey: string + detail: Message + /** Whether there is a link worth putting on screen to be copied. */ + showLink: boolean +} + +export function linkState(client: GuildOAuthClient | null): LinkState { + if (client === null) { + return { + tone: 'absent', + headingKey: 'admin.signInLink.stateAbsentHeading', + detail: { key: 'admin.signInLink.stateAbsentDetail' }, + showLink: false, + } + } + if (!client.hasSecret) { + return { + tone: 'incomplete', + headingKey: 'admin.signInLink.stateIncompleteHeading', + detail: { key: 'admin.signInLink.stateIncompleteDetail' }, + showLink: true, + } + } + return { + tone: 'live', + headingKey: 'admin.signInLink.stateLiveHeading', + detail: { key: 'admin.signInLink.stateLiveDetail', params: { slug: client.slug } }, + showLink: true, + } +} + +/** + * The sentence that keeps this page honest about its own reach. + * + * Rendered on the page, and kept here rather than loose in a template + * because it is a statement about the architecture and not a caption: a + * guild's client governs the **console sign-in** and never the Discord + * account link, which stays on the environment-configured client + * permanently. `link` does not hold the master key and cannot unwrap a + * guild's secret; the chart's `_helpers.tpl` refuses to render it onto that + * component at all. An interface that implied a guild could bring its own + * client for `/link` would be promising something the deployment is built + * to prevent. + */ +export const SCOPE_NOTE_KEY = 'admin.signInLink.scopeNote' + +/* -------------------------------------------------------------------- */ +/* The registration form */ +/* -------------------------------------------------------------------- */ + +/** Whether the guild comes back to this deployment's own callback — which + * is what nearly every guild wants and what `redirect_uri: null` means — + * or to one of its own. Two named states rather than an empty string + * standing in for the default, because "" and null are the same value in a + * text box and different values in this API. */ +export type RedirectMode = 'default' | 'custom' + +/** + * What the registration form edits. + * + * **There is no secret field, and its absence is the design.** `PUT` on a + * registration does not touch the stored secret — the API gives the + * credential two routes of its own precisely so that changing a base URL + * cannot clear it — so a password box on this form would either lie about + * what Save does or wipe a credential every time somebody corrected a + * client id. This type has nowhere to put one, so that failure is + * unrepresentable rather than merely avoided. `SignInClientSecret` is the + * control that writes it, with its own request. + * + * `provider` is carried rather than chosen. There is one value this + * deployment accepts and a dropdown with one row asks a question with one + * answer — but a form that dropped the field and hard-coded the constant + * into the body would silently rewrite the provider of a registration this + * console does not understand, which is `directory.ts`' rule about an + * unresolved snowflake applied to a word. + */ +export interface ClientDraft { + slug: string + provider: string + baseUrl: string + clientId: string + redirectMode: RedirectMode + redirectUri: string +} + +/** A blank registration, ready to be filled in. `default` because + * `redirect_uri: null` is what §6.2.12 says nearly every guild wants. */ +export function emptyClientDraft(): ClientDraft { + return { + slug: '', + provider: PROVIDER_OUTLINE, + baseUrl: '', + clientId: '', + redirectMode: 'default', + redirectUri: '', + } +} + +/** An existing registration, ready to be changed. */ +export function clientDraftOf(client: GuildOAuthClient): ClientDraft { + return { + slug: client.slug, + provider: client.provider, + baseUrl: client.baseUrl, + clientId: client.clientId, + redirectMode: client.redirectUri === null ? 'default' : 'custom', + redirectUri: client.redirectUri ?? '', + } +} + +/** Which field a complaint is about, so the page can put it beside that + * field rather than in a list of grievances at the bottom. */ +export type ClientDraftField = 'slug' | 'provider' | 'baseUrl' | 'clientId' | 'redirectUri' + +export interface ClientDraftProblem { + field: ClientDraftField + message: Message +} + +/** + * What is wrong with a draft, in the order the fields are read. + * + * Every rule here is one `_registration` already enforces. Restating them + * is the courtesy argued for above {@link slugProblem}, and it has a second + * effect worth naming: once this returns nothing, a 400 from these routes + * can only mean that this console and the deployment disagree about what a + * registration is — which is a different sentence from any of the five + * below, and {@link describeClientError} says so. + * + * The one rule that is **not** restated is availability. See the top of + * this module. + */ +export function clientDraftProblems(draft: ClientDraft): ClientDraftProblem[] { + const problems: ClientDraftProblem[] = [] + + const slug = slugProblem(draft.slug) + if (slug !== null) problems.push({ field: 'slug', message: slug }) + + if (draft.provider !== PROVIDER_OUTLINE) { + problems.push({ + field: 'provider', + message: { key: 'admin.signInLink.providerUnsupported', params: { provider: draft.provider } }, + }) + } + + if (draft.baseUrl === '') { + problems.push({ field: 'baseUrl', message: { key: 'admin.signInLink.baseUrlEmpty' } }) + } else if (draft.baseUrl.length > MAX_URL_LENGTH || !isProviderUrl(draft.baseUrl)) { + problems.push({ field: 'baseUrl', message: { key: 'admin.signInLink.urlShape' } }) + } + + if (draft.clientId.trim() === '') { + problems.push({ field: 'clientId', message: { key: 'admin.signInLink.clientIdEmpty' } }) + } else if (draft.clientId.length > MAX_CLIENT_ID_LENGTH) { + problems.push({ field: 'clientId', message: { key: 'admin.signInLink.clientIdLong' } }) + } + + if (draft.redirectMode === 'custom') { + if (draft.redirectUri === '') { + problems.push({ field: 'redirectUri', message: { key: 'admin.signInLink.redirectEmpty' } }) + } else if (draft.redirectUri.length > MAX_URL_LENGTH || !isProviderUrl(draft.redirectUri)) { + problems.push({ field: 'redirectUri', message: { key: 'admin.signInLink.urlShape' } }) + } + } + + return problems +} + +/** The complaint about one field, or `null`. */ +export function clientProblemFor( + problems: readonly ClientDraftProblem[], + field: ClientDraftField, +): Message | null { + return problems.find((problem) => problem.field === field)?.message ?? null +} + +/** Whether a draft may be submitted at all. */ +export function isClientDraftReady(draft: ClientDraft): boolean { + return clientDraftProblems(draft).length === 0 +} + +/** + * The body a registration sends. + * + * Five fields, and there is no sixth. Nothing here trims: `_registration` + * does not either, and a console that quietly trimmed a slug would store a + * name the administrator does not recognise in the link they were told to + * hand out. The one exception is the client id, which is an opaque token + * pasted out of another application's interface — a trailing newline off a + * clipboard is not a client id anybody chose, and it is the one field here + * whose value nobody reads back off a screen. + */ +export function clientDraftBody(draft: ClientDraft): Record { + return { + slug: draft.slug, + provider: draft.provider, + base_url: draft.baseUrl, + client_id: draft.clientId.trim(), + redirect_uri: draft.redirectMode === 'custom' ? draft.redirectUri : null, + } +} + +/* -------------------------------------------------------------------- */ +/* The credential */ +/* -------------------------------------------------------------------- */ + +/** + * What the secret control may do, and what it may say. + * + * The whole of the design is in what is *not* here. There is no `value`, no + * `masked`, no `reveal`: `PUT .../oauth-client/secret` is the only route + * that writes this credential, `DELETE` on the same path is the only one + * that forgets it, and no route anywhere returns it. A control that offered + * to show it would be offering something the API cannot serve, and a masked + * placeholder would be worse than that — a row of dots is a value, it says + * how long the credential is, and it promises a button nothing can honour. + * + * `canClear` is separate from `canReplace` because clearing is a separate + * act with a separate request, and because the alternative — a password box + * rendered empty beside a stored credential and saved with the rest of a + * form — silently wipes it every time somebody corrects a typo elsewhere. + * {@link ClientDraft} has no secret field at all, so that failure is not + * merely avoided, it is unrepresentable. + */ +export interface ClientSecretState { + /** Whether a credential is stored. The only thing known about it. */ + stored: boolean + statusKey: string + /** Storing the first one, or replacing the one that is there. */ + actionKey: string + /** Only where there is something to clear. */ + canClear: boolean +} + +export function clientSecretState(client: GuildOAuthClient): ClientSecretState { + return { + stored: client.hasSecret, + statusKey: client.hasSecret + ? 'admin.signInLink.secretStored' + : 'admin.signInLink.secretNone', + actionKey: client.hasSecret + ? 'admin.signInLink.secretReplace' + : 'admin.signInLink.secretSet', + canClear: client.hasSecret, + } +} + +/** + * Whether a typed credential may be submitted. + * + * Empty is refused here as well as by the API, which answers 400 to `""` — + * and refusing it in the control is what stops "save an empty box" from + * looking like a way to clear one. Clearing has its own button, and it is + * the only way. + * + * The upper bound is `_MAX_SECRET`, checked here so that a paste of the + * wrong thing entirely is refused before it is sent somewhere that would + * have to refuse it — and a refusal from the API is one this console cannot + * explain, because `apiError` keeps only the status. + */ +export function canSubmitClientSecret(typed: string): boolean { + return typed !== '' && typed.length <= MAX_SECRET_LENGTH +} + +/* -------------------------------------------------------------------- */ +/* Where the requests go */ +/* -------------------------------------------------------------------- */ + +export function clientPath(guildId: string): string { + return `/guilds/${encodeURIComponent(guildId)}/oauth-client` +} + +export function clientSecretPath(guildId: string): string { + return `${clientPath(guildId)}/secret` +} + +/* -------------------------------------------------------------------- */ +/* When a request does not work */ +/* -------------------------------------------------------------------- */ + +/** + * Whether a failed read means "this guild has no registration". + * + * `GET` answers 404 for a guild with no client **and** for a guild the + * caller does not administer **and** for a guild that does not exist — one + * answer, deliberately, because whether a given guild has its own sign-in + * is the fact §2.2 exists to keep undiscoverable. The page can be relaxed + * about that: it only ever asks about guilds the viewer administers, so the + * one reading it can act on is "there is nothing registered yet", which is + * a state and not an error. + */ +export function isMissingRegistration(error: unknown): boolean { + return (error as { status?: unknown } | null)?.status === 404 +} + +/** + * Why a write failed, from its status and nothing else. + * + * The status is all there is: `sanitiseFetchError` keeps nothing from a + * failed response but that, on purpose, so that no page can accidentally + * render an internal hostname out of a `$fetch` error. + * + * The 409 is the interesting one. It means "that sign-in name is not + * available", and this console says exactly that and no more — it does not + * say whether the name is held by another guild or reserved by the + * deployment, because the API refuses to say, and the reason it refuses is + * that the held ones are what §2.2 does not want enumerable. A console that + * expanded the sentence would undo the endpoint's discretion from the + * outside. + */ +export function describeClientError(error: unknown): Message { + const held = (error as { status?: unknown } | null)?.status + const status = typeof held === 'number' ? held : null + switch (status) { + case 400: + return { key: 'admin.signInLink.errorRefused' } + case 401: + return { key: 'admin.signInLink.errorSession' } + case 404: + return { key: 'admin.signInLink.errorGone' } + case 409: + return { key: 'admin.signInLink.errorNameTaken' } + case 0: + case null: + return { key: 'admin.signInLink.errorUnreachable' } + default: + return { key: 'admin.signInLink.errorStatus', params: { status: String(status) } } + } +} diff --git a/console/i18n/README.md b/console/i18n/README.md index 77db10e..28defa6 100644 --- a/console/i18n/README.md +++ b/console/i18n/README.md @@ -38,7 +38,7 @@ matches the file that renders it: | ------------------ | --------------------------------------------------- | | `common.*` | Strings with no single home — the product name, a duration | | `nav.*` | `utils/navigation.ts`, `AppSidebar`, the header burger | -| `auth.*` | `pages/sign-in.vue`, signing out | +| `auth.*` | `pages/sign-in.vue`, `pages/g/[slug]/sign-in.vue`, signing out | | `error.*` | `error.vue` | | `dashboard.*` | `pages/index.vue`, `utils/format.ts` | | `recordings.*` | `pages/recordings/*` and the components under them | @@ -46,6 +46,7 @@ matches the file that renders it: | `settings.*` | `pages/settings.vue` — a person's own settings | | `admin.settings.*` | `pages/admin/bot-settings.vue` | | `admin.destinations.*` | `pages/admin/destinations.vue`, `utils/exportTargets.ts`, the two `ExportTarget*` components | +| `admin.signInLink.*` | `pages/admin/sign-in-link.vue`, `utils/oauthClient.ts`, the two `SignInClient*` components | | `admin.consents.*` | `pages/admin/consents.vue` | | `admin.queue.*` | `pages/admin/queue.vue` | | `admin.reporting.*`| `pages/admin/reporting.vue` | @@ -85,6 +86,15 @@ name (`common.formatOutline` and its two neighbours) lives in `common.*`, because the recording page has to say the same three words beside a published document and two copies of a word are two words that drift. +`admin.signInLink.*` is complete from the start on the same terms, and for +the same reason: `/admin/sign-in-link` is a new page and never had any +hard-coded English for the sweep to reach. The page a guild's link points +at is **not** in it — `/g/{slug}/sign-in` is keyed under `auth.*`, beside +the deployment's own sign-in page, because the two are the same screen for +the same act and a reader who has been sent to one of them has no way to +tell which. A namespace named after an administrative page would put those +two sentences in different files and let them drift apart. + `admin.queue.*` is half-populated on the same terms. The Queue page's older prose — its four lifecycle notes, its three caveats, its per-row state sentences — is still hard-coded English in `utils/queue.ts` and moves when diff --git a/console/i18n/locales/de.json b/console/i18n/locales/de.json index 8c5c15e..d431935 100644 --- a/console/i18n/locales/de.json +++ b/console/i18n/locales/de.json @@ -24,7 +24,8 @@ "queue": "Warteschlange", "reporting": "Auswertung", "consents": "Einwilligungen", - "destinations": "Ziele" + "destinations": "Ziele", + "signInLink": "Anmeldelink" }, "auth": { "signIn": "Anmelden", @@ -32,7 +33,11 @@ "signInWithOutline": "Mit Outline anmelden", "tagline": "Besprechungsprotokolle und die Aufnahmen dahinter.", "notLinkedHeading": "Dieses Konto ist noch nicht verknüpft.", - "notLinkedBody": "Dazu {command} in Discord ausführen und sich danach erneut anmelden. Die Konsole findet Aufnahmen über das Discord-Konto, und die Verknüpfung stellt diese Verbindung her." + "notLinkedBody": "Dazu {command} in Discord ausführen und sich danach erneut anmelden. Die Konsole findet Aufnahmen über das Discord-Konto, und die Verknüpfung stellt diese Verbindung her.", + "guildSignIn": "Server-Anmeldung", + "guildTagline": "Anmeldelink für {slug}. Er führt zu dem Outline, für das dieser Name registriert ist — nicht zwingend zu dem, das diese Installation selbst verwendet.", + "guildUnknownNote": "Falls das nicht funktioniert, ist der Name hier entweder nicht registriert, oder seine Registrierung ist nicht abgeschlossen. Diese Seite kann beides nicht unterscheiden, und niemand sonst kann es — dann bitte bei der Person nachfragen, von der der Link stammt.", + "guildUseCentral": "Stattdessen mit dem Outline dieser Installation anmelden" }, "profile": { "menu": "Kontomenü", @@ -741,6 +746,92 @@ "fallbackInUse": "Hier ist kein Ziel eingeschaltet, deshalb veröffentlicht dieser Server weiterhin dorthin, wohin document_target in den Bot-Einstellungen zeigt: ein Outline-Dokument je Besprechung. Ein hier eingeschaltetes Ziel übernimmt das vollständig.", "fallbackReplaced": "Dieser Server veröffentlicht an das eine Ziel oben, und document_target in den Bot-Einstellungen wird dafür nicht mehr benutzt — die Ziele hier ersetzen jene Einstellung, statt sie zu ergänzen. | Dieser Server veröffentlicht an die {count} Ziele oben, und document_target in den Bot-Einstellungen wird dafür nicht mehr benutzt — die Ziele hier ersetzen jene Einstellung, statt sie zu ergänzen.", "fallbackLink": "Bot-Einstellungen öffnen" + }, + "signInLink": { + "title": "Anmeldelink", + "intro": "Ein Server kann seine Mitglieder über sein eigenes Outline anmelden statt über das, mit dem diese Installation konfiguriert ist — über einen Link, den seine Administratoren selbst verteilen.", + "optional": "Nichts davon ist erforderlich. Ein Server, der hier nichts einträgt, meldet sich weiterhin genau wie bisher über das Outline dieser Installation an.", + "whichServer": "Welcher Server", + "serversFailed": "Die von diesem Konto administrierten Server konnten nicht gelesen werden, es gibt also noch nichts zu konfigurieren.", + "noGuildsHeading": "Hier gibt es noch nichts", + "noGuildsBody": "Diese Seite gibt einem Discord-Server einen eigenen Anmeldelink und steht den Administratoren eines Servers offen, auf dem Sturnus läuft. Derzeit wird keiner davon von diesem Konto administriert.", + "noGuildsRole": "Administratoren sind die Mitglieder mit der Discord-Rolle, die der Server in seiner Einstellung {setting} nennt. Wer sie schon hat, kann sie weitergeben — Sturnus spiegelt die Mitgliedschaft aus Discord, die Änderung erreicht diese Konsole also von selbst.", + "loading": "Die Anmeldekonfiguration dieses Servers wird gelesen…", + "loadFailed": "Die Anmeldekonfiguration dieses Servers konnte nicht gelesen werden.", + "stateAbsentHeading": "Dieser Server hat keinen eigenen Anmeldelink", + "stateAbsentDetail": "Seine Mitglieder melden sich über das Outline dieser Installation an. Das ist der Normalfall und erfordert von dieser Seite nichts.", + "stateIncompleteHeading": "Dieser Link funktioniert noch nicht", + "stateIncompleteDetail": "Der Client ist registriert, sein Secret wurde aber noch nicht hinterlegt. Der Link antwortet daher genau wie ein Name, den niemand registriert hat — absichtlich dieselbe Antwort, damit von außen niemand einen halb konfigurierten Server von einem unterscheiden kann, den es hier gar nicht gibt. Sobald das Client-Secret unten hinterlegt ist, funktioniert der Link.", + "stateLiveHeading": "Dieser Link ist aktiv", + "stateLiveDetail": "Wer ihm folgt, meldet sich über das eigene Outline dieses Servers an. Sturnus veröffentlicht ihn nirgends — {slug} erreicht nur, wer den Link bekommen hat.", + "linkLabel": "Der Link zum Weitergeben", + "linkHint": "Die Verteilung übernimmt der Server selbst. Es gibt nirgends eine Seite, die die von Sturnus bedienten Server auflistet, und genau dieses Fehlen verhindert, dass diese Installation preisgibt, welche Organisationen sie nutzen.", + "copy": "Kopieren", + "copied": "Kopiert", + "registrationHeading": "Der OAuth-Client", + "registrationNone": "Für diesen Server ist nichts registriert. Zuerst im eigenen Outline eine OAuth-Anwendung anlegen, deren Redirect-URI auf den Konsolen-Callback dieser Installation zeigt, und deren Daten anschließend hier eintragen.", + "registerClient": "Client registrieren", + "changeRegistration": "Registrierung ändern", + "removeRegistration": "Registrierung entfernen", + "removeConfirmHeading": "Diesen Anmeldelink entfernen?", + "removeConfirmBody": "Der Link funktioniert sofort nicht mehr und sein Name wird freigegeben, kann also danach von einem anderen Server beansprucht werden. Das gespeicherte Client-Secret verschwindet mit ihm. Bereits angemeldete Sitzungen bleiben bestehen, und die Mitglieder dieses Servers können sich weiterhin über das Outline dieser Installation anmelden.", + "removeConfirm": "Ja, entfernen", + "registerHeading": "OAuth-Client dieses Servers registrieren", + "changeHeading": "OAuth-Client dieses Servers ändern", + "slugLabel": "Anmeldename", + "slugHint": "3 bis 32 Zeichen: Kleinbuchstaben, Ziffern und einzelne Bindestriche, beginnend mit einem Buchstaben. Der Buchstabe am Anfang ist das, was einen Anmeldenamen und eine Discord-Server-ID in einem gelesenen Link unverwechselbar macht.", + "slugPermanenceNew": "Er wird Teil einer öffentlichen Adresse; also ein Wort wählen, unter dem dieser Server bekannt sein möchte. Nichts wird stillschweigend korrigiert: Ein Großbuchstabe wird zurückgewiesen statt kleingeschrieben, denn ein beim Speichern umgeschriebener Name ist ein Name, den im weitergegebenen Link niemand wiedererkennt.", + "slugPermanenceChange": "Eine Änderung ändert den Link. Bereits weitergegebene Links funktionieren sofort nicht mehr, und der alte Name wird für andere Server freigegeben.", + "slugEmpty": "Ein Anmeldename fehlt — er ist das Wort, das im Link steht.", + "slugLength": "Ein Anmeldename ist zwischen {min} und {max} Zeichen lang.", + "slugShape": "Nur Kleinbuchstaben, Ziffern und einzelne Bindestriche, beginnend mit einem Buchstaben. Keine Leerzeichen, keine Großbuchstaben und kein Bindestrich am Anfang oder Ende.", + "baseUrlLabel": "Outline-Adresse", + "baseUrlHint": "Wo das eigene Outline dieses Servers liegt — etwa https://outline.acme.example. Ein Pfad ist erlaubt, ein Query-String oder ein Fragment nicht, denn beides erzeugt Sturnus selbst.", + "baseUrlEmpty": "Die Adresse des Outline dieses Servers fehlt.", + "urlShape": "Eine https-Adresse ohne Benutzernamen, ohne Query-String und ohne Fragment. Einfaches http wird zurückgewiesen, weil der Autorisierungscode darüber läuft, und ein Benutzername vor dem Host, weil die Adresse dadurch nach einem Host aussieht und einen anderen erreicht.", + "clientIdLabel": "Client-ID", + "clientIdHint": "Aus der in jenem Outline angelegten OAuth-Anwendung. Diese Hälfte des Paars ist kein Geheimnis, wird deshalb hier angezeigt und darf zurückgelesen werden.", + "clientIdEmpty": "Die Client-ID jener OAuth-Anwendung fehlt.", + "clientIdLong": "Das ist länger, als eine Client-ID sein kann. Bitte prüfen, ob hier versehentlich ein Secret eingefügt wurde.", + "redirectOwnLabel": "Dieser Server verwendet eine eigene Callback-Adresse", + "redirectDefaultHint": "Ohne besonderen Grund unverändert lassen: Die OAuth-Anwendung kehrt dann zum Konsolen-Callback dieser Installation zurück, was fast jeder Server möchte.", + "redirectOwnHint": "Die Adresse, zu der die OAuth-Anwendung nach der Autorisierung zurückkehrt. Der Eintrag muss exakt dem in jenem Outline hinterlegten entsprechen.", + "redirectLabel": "Callback-Adresse", + "redirectDefaultValue": "der Callback dieser Installation", + "redirectEmpty": "Eine Callback-Adresse fehlt — oder das Häkchen oben entfernen, um den Callback dieser Installation zu verwenden.", + "providerLabel": "Identitätsanbieter", + "providerHint": "Outline ist der einzige Anbieter, mit dem diese Installation eine Anmeldung abschließen kann; es gibt hier also nichts zu wählen. Eine Registrierung auf etwas anderes wäre ein Link, der dauerhaft und unbemerkt kaputt ist.", + "providerUnsupported": "Diese Registrierung nennt {provider}; damit kann diese Installation keine Anmeldung abschließen. Ein Speichern ließe den Link kaputt zurück, ohne dass etwas gewonnen wäre.", + "save": "Speichern", + "saving": "Wird gespeichert…", + "cancel": "Abbrechen", + "secretHeading": "Client-Secret", + "secretStored": "Für diesen Server ist ein Client-Secret gespeichert.", + "secretNone": "Es ist kein Client-Secret gespeichert, der Link funktioniert daher noch nicht.", + "secretNeverShown": "Sturnus zeigt ein gespeichertes Client-Secret nirgends an, auch auf dieser Seite nicht: Es ist gegen diesen Server und diesen Zweck verschlüsselt, und keine Route gibt es zurück. Ersetzen und Löschen sind das Einzige, was damit möglich ist, und beides hat eine eigene Schaltfläche — ein leeres Feld neben einem gespeicherten Secret ist der Weg, auf dem es beim Korrigieren eines Tippfehlers verschwindet.", + "secretRotationNote": "Nach einem Wechsel des Hauptschlüssels lohnt sich ein erneutes Eintragen. Ein Secret, das mit einem Schlüssel verpackt wurde, den diese Installation nicht mehr hält, lässt den Link von außen unbemerkt aufhören zu funktionieren; das erneute Eintragen verpackt es neu.", + "secretReplace": "Client-Secret ersetzen", + "secretSet": "Client-Secret hinterlegen", + "secretClear": "Gespeichertes Secret löschen", + "secretInputLabel": "Neues Client-Secret", + "secretSave": "Hinterlegen", + "secretSaving": "Wird hinterlegt…", + "secretClearConfirmHeading": "Dieses Client-Secret löschen?", + "secretClearConfirmBody": "Danach ist es nicht wiederherstellbar — Sturnus hält keine Kopie, die irgendetwas zurücklesen könnte. Der Link funktioniert sofort nicht mehr und antwortet wie ein nicht registrierter Name, während die Registrierung und ihr Name erhalten bleiben. Genau das ist zu tun, wenn ein Secret abgeflossen ist.", + "secretClearConfirm": "Ja, löschen", + "savedRegistered": "Der Client ist registriert. Der Link funktioniert, sobald sein Secret hinterlegt ist.", + "savedChanged": "Die Registrierung wurde geändert.", + "savedRemoved": "Die Registrierung wurde entfernt und ihr Name freigegeben.", + "savedSecretSet": "Das Client-Secret wurde hinterlegt. Der Link funktioniert ab sofort.", + "savedSecretCleared": "Das Client-Secret wurde gelöscht. Der Link antwortet wie ein nicht registrierter Name, bis ein neues hinterlegt ist.", + "scopeHeading": "Was das nicht abdeckt", + "scopeNote": "Das gilt für die Anmeldung an der Konsole und für sonst nichts. Das Verknüpfen eines Discord-Kontos mit {command} läuft dauerhaft über den Client dieser Installation: Der Prozess dahinter hält den Hauptschlüssel nicht und kann das Secret eines Servers deshalb gar nicht auspacken — und genau das hält die zum Internet hin offene Hälfte von Sturnus davon ab, überhaupt etwas entschlüsseln zu können.", + "errorRefused": "Sturnus hat diese Registrierung zurückgewiesen. Jedes Feld, das diese Konsole prüft, war bereits in Ordnung; ihre Vorstellung einer gültigen Registrierung und die dieser Installation stimmen also nicht mehr überein.", + "errorSession": "Die Sitzung ist abgelaufen. Erneut anmelden und noch einmal versuchen — es wurde nichts geschrieben.", + "errorGone": "Sturnus kennt diesen Server nicht mehr, oder dieses Konto administriert ihn nicht mehr, oder für ihn ist nichts registriert — die Antwort ist auf alle drei absichtlich dieselbe. Seite neu laden.", + "errorNameTaken": "Dieser Anmeldename ist nicht verfügbar. Sturnus sagt nicht, ob er anderweitig vergeben oder für sich selbst reserviert ist, und wird es auch nicht sagen — bitte einen anderen wählen.", + "errorUnreachable": "Die API war nicht erreichbar. Es wurde nichts geschrieben; Verbindung prüfen und noch einmal versuchen.", + "errorStatus": "Sturnus hat mit {status} geantwortet und keinen Grund genannt. Was dabei geschrieben wurde, falls überhaupt etwas, ist nicht bekannt." } }, "ui": { diff --git a/console/i18n/locales/en.json b/console/i18n/locales/en.json index be9297a..9800f28 100644 --- a/console/i18n/locales/en.json +++ b/console/i18n/locales/en.json @@ -24,7 +24,8 @@ "queue": "Queue", "reporting": "Reporting", "consents": "Consents", - "destinations": "Destinations" + "destinations": "Destinations", + "signInLink": "Sign-in link" }, "auth": { "signIn": "Sign in", @@ -32,7 +33,11 @@ "signInWithOutline": "Sign in with Outline", "tagline": "Meeting protocols, and the recordings behind them.", "notLinkedHeading": "Your account is not linked yet.", - "notLinkedBody": "Run {command} in Discord, then sign in again. The console finds your recordings by your Discord account, and the link is what connects the two." + "notLinkedBody": "Run {command} in Discord, then sign in again. The console finds your recordings by your Discord account, and the link is what connects the two.", + "guildSignIn": "Server sign-in", + "guildTagline": "Sign-in link for {slug}. It goes to whichever Outline that name is registered against, which need not be the one this deployment uses itself.", + "guildUnknownNote": "If this does not work, either the name is not registered here or its registration is not finished. This page cannot tell those apart, and neither can anybody else — ask whoever sent you the link.", + "guildUseCentral": "Sign in with this deployment’s own Outline instead" }, "profile": { "menu": "Account menu", @@ -741,6 +746,92 @@ "fallbackInUse": "No destination here is switched on, so this server still publishes where document_target on Bot Settings points: one Outline document per meeting. Switching one on here takes over from that setting completely.", "fallbackReplaced": "This server publishes to the one destination above, and document_target on Bot Settings is no longer used for it — the destinations here replace that setting rather than adding to it. | This server publishes to the {count} destinations above, and document_target on Bot Settings is no longer used for them — the destinations here replace that setting rather than adding to it.", "fallbackLink": "Open Bot Settings" + }, + "signInLink": { + "title": "Sign-in link", + "intro": "A server can sign its people in against its own Outline rather than the one this deployment is configured with, through a link its administrators hand out themselves.", + "optional": "None of this is required. A server that configures nothing here goes on signing in exactly as it does today, through this deployment’s own Outline.", + "whichServer": "Which server", + "serversFailed": "The servers administered by this account could not be read, so there is nothing to configure yet.", + "noGuildsHeading": "There is nothing here for you yet", + "noGuildsBody": "This page gives one Discord server a sign-in link of its own, and it is open to the administrators of a server where Sturnus is running. This account administers none of them right now.", + "noGuildsRole": "Administrators are the members holding the Discord role that server names in its {setting} setting. Somebody who already has it can grant you that role — Sturnus mirrors the membership from Discord, so the change reaches this console on its own.", + "loading": "Reading this server’s sign-in configuration…", + "loadFailed": "This server’s sign-in configuration could not be read.", + "stateAbsentHeading": "This server has no sign-in link", + "stateAbsentDetail": "Its members sign in through this deployment’s own Outline, which is the ordinary arrangement and needs nothing from this page.", + "stateIncompleteHeading": "This link does not work yet", + "stateIncompleteDetail": "The client is registered but its secret has not been supplied, so the link answers exactly as a name nobody has registered — the same reply, deliberately, so that a stranger walking a list of names cannot tell a half-configured server from one that is not here at all. Store the client secret below and the link starts working.", + "stateLiveHeading": "This link is live", + "stateLiveDetail": "Anybody who follows it signs in through this server’s own Outline. Sturnus never publishes it anywhere — {slug} is only reachable by somebody who was given it.", + "linkLabel": "The link to hand out", + "linkHint": "Distribute this yourself. There is no page anywhere that lists the servers Sturnus serves, and that absence is what keeps this deployment from disclosing which organisations use it.", + "copy": "Copy", + "copied": "Copied", + "registrationHeading": "The OAuth client", + "registrationNone": "Nothing is registered for this server. Register an OAuth application in its own Outline first, pointing its redirect URI at this deployment’s console callback, then enter its details here.", + "registerClient": "Register a client", + "changeRegistration": "Change the registration", + "removeRegistration": "Remove the registration", + "removeConfirmHeading": "Remove this sign-in link?", + "removeConfirmBody": "The link stops working at once and its name is released, so another server may claim it afterwards. The stored client secret goes with it. Nobody signed in loses their session, and this server’s members can still sign in through this deployment’s own Outline.", + "removeConfirm": "Yes, remove it", + "registerHeading": "Register this server’s OAuth client", + "changeHeading": "Change this server’s OAuth client", + "slugLabel": "Sign-in name", + "slugHint": "3 to 32 characters: lowercase letters, digits and single hyphens, beginning with a letter. Beginning with a letter is what keeps a sign-in name and a Discord server id from being confusable in a link somebody reads.", + "slugPermanenceNew": "It becomes part of a public address, so choose a word this server is content to be known by. Nothing is corrected for you: a capital letter is refused rather than lowercased, because a name quietly rewritten on the way in is a name nobody recognises in the link they handed out.", + "slugPermanenceChange": "Changing it changes the link. Anything already handed out stops working immediately, and the old name is released for another server to claim.", + "slugEmpty": "A sign-in name is needed — it is the word that appears in the link.", + "slugLength": "A sign-in name is between {min} and {max} characters long.", + "slugShape": "Lowercase letters, digits and single hyphens only, beginning with a letter. No spaces, no capitals, and no hyphen at either end.", + "baseUrlLabel": "Outline address", + "baseUrlHint": "Where this server’s own Outline lives — for example https://outline.acme.example. A path is fine; a query string or a fragment is not, because Sturnus builds its own.", + "baseUrlEmpty": "The address of this server’s Outline is needed.", + "urlShape": "An https address, with no username, no query string and no fragment. Plain http is refused because the authorisation code travels over this, and a username in front of the host is refused because it makes the address read as one host and reach another.", + "clientIdLabel": "Client id", + "clientIdHint": "From the OAuth application registered in that Outline. It is the half of the pair that is not a secret, so it is shown here and is safe to read back.", + "clientIdEmpty": "The client id of that OAuth application is needed.", + "clientIdLong": "That is longer than a client id can be. Check that a secret has not been pasted into this field by mistake.", + "redirectOwnLabel": "This server uses a callback address of its own", + "redirectDefaultHint": "Leave this as it is unless there is a reason not to: the OAuth application returns to this deployment’s own console callback, which is what nearly every server wants.", + "redirectOwnHint": "The address the OAuth application returns to after somebody authorises. It must match what is registered in that Outline exactly.", + "redirectLabel": "Callback address", + "redirectDefaultValue": "this deployment’s own callback", + "redirectEmpty": "A callback address is needed, or untick the box above to use this deployment’s own.", + "providerLabel": "Identity provider", + "providerHint": "Outline is the only provider this deployment can complete a sign-in against, so there is nothing to choose. A registration against anything else would be a link that is permanently and silently broken.", + "providerUnsupported": "This registration names {provider}, which this deployment cannot exchange a sign-in with. Saving it would leave the link broken with nothing to show for it.", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "secretHeading": "Client secret", + "secretStored": "A client secret is stored for this server.", + "secretNone": "No client secret is stored, so the link does not work yet.", + "secretNeverShown": "Sturnus shows a stored client secret nowhere, this page included: it is encrypted against this server and this purpose, and no route returns it. Replacing and clearing are the only things that can be done with it, and each has its own button — an empty box beside a stored secret is how one disappears while somebody is correcting a typo elsewhere.", + "secretRotationNote": "It is also worth re-entering after a master-key rotation. A secret wrapped by a key this deployment no longer holds makes the link stop working silently from the outside, and typing it again is what re-wraps it.", + "secretReplace": "Replace the client secret", + "secretSet": "Store the client secret", + "secretClear": "Clear the stored secret", + "secretInputLabel": "New client secret", + "secretSave": "Store it", + "secretSaving": "Storing…", + "secretClearConfirmHeading": "Clear this client secret?", + "secretClearConfirmBody": "It cannot be recovered afterwards — Sturnus keeps no copy anything could read back. The link stops working at once and starts answering exactly as an unregistered name, while the registration and its name stay yours. That is what to do when a secret has leaked.", + "secretClearConfirm": "Yes, clear it", + "savedRegistered": "The client is registered. The link starts working once its secret is stored.", + "savedChanged": "The registration was changed.", + "savedRemoved": "The registration was removed and its name released.", + "savedSecretSet": "The client secret was stored. The link works from now on.", + "savedSecretCleared": "The client secret was cleared. The link answers as an unregistered name until a new one is stored.", + "scopeHeading": "What this does not cover", + "scopeNote": "This governs signing in to the console, and nothing else. Linking a Discord account with {command} always runs against this deployment’s own client, permanently: the process that serves it does not hold the master key and therefore cannot unwrap a server’s secret — which is what keeps the internet-facing half of Sturnus unable to decrypt anything at all.", + "errorRefused": "Sturnus refused this registration. Every field this console checks had already passed, so its idea of a valid registration and this deployment’s no longer agree.", + "errorSession": "The session has expired. Sign in again and retry — nothing was written.", + "errorGone": "Sturnus no longer knows this server, or this account no longer administers it, or there is nothing registered for it — the reply is the same for all three, on purpose. Reload the page.", + "errorNameTaken": "That sign-in name is not available. Sturnus does not say whether it is held elsewhere or reserved for itself, and will not — choose a different one.", + "errorUnreachable": "The API could not be reached. Nothing was written; check the connection and retry.", + "errorStatus": "Sturnus answered {status} and gave no reason. What was written, if anything, is unknown." } }, "ui": { diff --git a/console/test/navigation.spec.ts b/console/test/navigation.spec.ts index 11e962d..a7e6680 100644 --- a/console/test/navigation.spec.ts +++ b/console/test/navigation.spec.ts @@ -174,9 +174,15 @@ describe('who is offered the Admin View', () => { // it became a page, and `document_target` is still the fallback for a // guild that configures nothing here. Two adjacent entries is what // stops them reading as rival settings. + // + // The sign-in link sits third, closing the run of three that configure + // a guild, and next to Bot Settings for the same kind of reason: what a + // guild's own OAuth client decides is who gets a session, and + // `admin_role_id` over there decides who may then reach any of this. expect(ADMIN_VIEW.entries.map((e) => e.labelKey)).toEqual([ 'nav.botSettings', 'nav.destinations', + 'nav.signInLink', 'nav.consents', 'nav.queue', 'nav.reporting', diff --git a/console/test/oauthClient.spec.ts b/console/test/oauthClient.spec.ts new file mode 100644 index 0000000..99c5a6a --- /dev/null +++ b/console/test/oauthClient.spec.ts @@ -0,0 +1,565 @@ +/** + * A guild's own sign-in link, decided without a browser. + * + * Two of the properties checked here are security decisions rather than + * preferences, and both of them are invisible in a rendered frame: + * + * - **Nothing in this module can carry a client secret.** The type the form + * edits has no field for one, so the checks below assert an absence — + * which is the only way an absence stays true after somebody adds a + * field "just for the edit case". + * - **Nothing in this module asks whether a slug is free.** A console that + * could answer that would be an oracle for which organisations use this + * service, which is the one fact §2.2 is built to withhold. The refusal + * the API gives for a taken name and for a reserved one is one refusal, + * and the sentence this console renders for it is one sentence. + * + * The slug and URL rules are a second copy of + * `sturnus.domain.oauth_clients`, so the cases below are lifted from what + * that module's own tests pin: a copy that has quietly stopped agreeing + * with the original is worse than no copy, because it refuses registrations + * the deployment would have accepted. + */ +import { describe, expect, it } from 'vitest' + +import { + MAX_SECRET_LENGTH, + PROVIDER_OUTLINE, + type ClientDraft, + type GuildOAuthClient, + canSubmitClientSecret, + clientPath, + clientSecretPath, + describeClientError, + clientDraftBody, + clientDraftOf, + emptyClientDraft, + isClientDraftReady, + isGuildSignInPath, + isMissingRegistration, + isProviderUrl, + linkState, + loginUrl, + parseClient, + clientProblemFor, + clientDraftProblems, + clientSecretState, + signInPath, + signInUrl, + slugProblem, +} from '../app/utils/oauthClient' + +const GUILD = '1289374650912837465' + +const REGISTERED: GuildOAuthClient = { + guildId: GUILD, + slug: 'acme', + provider: PROVIDER_OUTLINE, + baseUrl: 'https://outline.acme.example', + clientId: 'client-abc', + redirectUri: null, + hasSecret: true, + createdAt: '2026-08-01T09:00:00+00:00', + updatedAt: '2026-08-02T09:00:00+00:00', +} + +/** A draft that passes every rule, so that each test below can break one + * thing and be about that thing. */ +function ready(overrides: Partial = {}): ClientDraft { + return { + slug: 'acme', + provider: PROVIDER_OUTLINE, + baseUrl: 'https://outline.acme.example', + clientId: 'client-abc', + redirectMode: 'default', + redirectUri: '', + ...overrides, + } +} + +describe('reading what the API sent', () => { + it('unwraps the registration from the envelope the API answers with', () => { + const client = parseClient({ + guild_id: GUILD, + oauth_client: { + slug: 'acme', + provider: 'outline', + base_url: 'https://outline.acme.example', + client_id: 'client-abc', + redirect_uri: null, + has_secret: true, + created_at: '2026-08-01T09:00:00+00:00', + updated_at: '2026-08-02T09:00:00+00:00', + }, + }) + expect(client).toEqual(REGISTERED) + }) + + it('keeps the guild id a string', () => { + // A snowflake through `Number` loses its last digits and becomes an id + // that looks right and names nobody. Every path this value takes -- the + // request URL of every write on the page -- is a path where being one + // digit out is silent. + const client = parseClient({ guild_id: GUILD, oauth_client: { slug: 'acme' } }) + expect(client?.guildId).toBe(GUILD) + expect(typeof client?.guildId).toBe('string') + }) + + it('has nowhere to put a secret, even when one is sent', () => { + // Nothing sends this. The check is that if anything ever did -- a + // debugging endpoint, a mistaken echo in a handler -- it would not + // survive parsing and reach a template. + const client = parseClient({ + guild_id: GUILD, + oauth_client: { slug: 'acme', has_secret: true, client_secret: 'sh-do-not-keep-this' }, + }) + expect(JSON.stringify(client)).not.toContain('do-not-keep-this') + expect(Object.keys(client ?? {})).not.toContain('clientSecret') + }) + + it('reads a registration that has no secret yet as having none', () => { + const client = parseClient({ oauth_client: { slug: 'acme' } }) + expect(client?.hasSecret).toBe(false) + }) + + it('treats an absent redirect_uri as this deployment’s own callback', () => { + expect(parseClient({ oauth_client: { slug: 'acme' } })?.redirectUri).toBeNull() + expect( + parseClient({ oauth_client: { slug: 'acme', redirect_uri: null } })?.redirectUri, + ).toBeNull() + }) + + it('keeps a redirect URI a guild set for itself', () => { + const client = parseClient({ + oauth_client: { slug: 'acme', redirect_uri: 'https://console.acme.example/callback' }, + }) + expect(client?.redirectUri).toBe('https://console.acme.example/callback') + }) + + it('reads a bare registration as well as an enveloped one', () => { + expect(parseClient({ slug: 'acme' })?.slug).toBe('acme') + }) + + it('is nothing at all when there is no slug to name it by', () => { + // Every remedy for a registration with no name is reachable from the + // state where there is no registration, so this is the honest reading + // rather than a lossy one. + expect(parseClient({ oauth_client: { slug: '' } })).toBeNull() + expect(parseClient({ oauth_client: {} })).toBeNull() + expect(parseClient(null)).toBeNull() + expect(parseClient('acme')).toBeNull() + expect(parseClient([])).toBeNull() + }) +}) + +describe('what a sign-in name may be', () => { + it('accepts the shapes the deployment accepts', () => { + for (const slug of ['acme', 'acme-corp', 'a1b', 'a-1-b', 'x'.repeat(32)]) { + expect(slugProblem(slug), `${slug} was refused`).toBeNull() + } + }) + + it('insists on a letter first, so a slug and a snowflake cannot be confused', () => { + // `/g/1289374650912837465/sign-in` and a guild id in a path are the + // same string to whoever reads the link. + expect(slugProblem('1289374650912837465')).not.toBeNull() + expect(slugProblem('9acme')).not.toBeNull() + expect(slugProblem('-acme')).not.toBeNull() + }) + + it('refuses a capital rather than lowercasing it', () => { + // A slug quietly rewritten on the way into the table is a slug the + // administrator does not recognise in the link they handed out. The + // complaint is about the shape, and the value is untouched. + expect(slugProblem('Acme')).toEqual({ key: 'admin.signInLink.slugShape' }) + }) + + it('refuses whitespace rather than trimming it', () => { + for (const slug of [' acme', 'acme ', 'ac me']) { + expect(slugProblem(slug), `${slug} was accepted`).not.toBeNull() + } + }) + + it('refuses a leading, trailing or doubled hyphen', () => { + for (const slug of ['acme-', 'ac--me', '-acme']) { + expect(slugProblem(slug), `${slug} was accepted`).not.toBeNull() + } + }) + + it('says which mistake was made rather than "invalid"', () => { + // Three different typing mistakes, and one sentence for all three + // tells nobody which of them they made. + expect(slugProblem('')?.key).toBe('admin.signInLink.slugEmpty') + expect(slugProblem('ab')?.key).toBe('admin.signInLink.slugLength') + expect(slugProblem('x'.repeat(33))?.key).toBe('admin.signInLink.slugLength') + expect(slugProblem('Acme')?.key).toBe('admin.signInLink.slugShape') + }) + + it('never says whether a name is free', () => { + // The one property of this page that is a security decision. There is + // no reserved list here, no availability call, and therefore no way for + // the console to distinguish "another organisation holds this" from + // "this deployment reserves it" -- which is exactly the distinction the + // API collapsed into a single 409 on purpose. + const source = slugProblem.toString() + clientDraftProblems.toString() + for (const reserved of ['login', 'static', 'console', 'well-known']) { + expect(source, `the console carries a copy of the reserved list`).not.toContain(reserved) + } + // A reserved name is spelled like a slug, so shape has nothing to say + // about it and the API is left to answer. + expect(slugProblem('login')).toBeNull() + expect(slugProblem('static')).toBeNull() + }) +}) + +describe('what an identity provider’s address may be', () => { + it('accepts an ordinary https deployment, with or without a path', () => { + expect(isProviderUrl('https://outline.acme.example')).toBe(true) + expect(isProviderUrl('https://wiki.example/outline')).toBe(true) + expect(isProviderUrl('https://wiki.example:8443/outline')).toBe(true) + }) + + it('refuses http, because the authorization code travels over it', () => { + expect(isProviderUrl('http://outline.acme.example')).toBe(false) + }) + + it('refuses a userinfo section', () => { + // `https://console.example@evil.example/` names `evil.example` and + // reads to a human as the first host. It is the one form where + // refusing to parse is the difference between what an administrator + // reviewing the value sees and what a browser does. + expect(isProviderUrl('https://console.example@evil.example/')).toBe(false) + expect(isProviderUrl('https://user:pass@evil.example/')).toBe(false) + }) + + it('refuses a query or a fragment', () => { + // `authorize_url` builds its own query string; a fragment never + // reaches a server at all. + expect(isProviderUrl('https://outline.acme.example?a=1')).toBe(false) + expect(isProviderUrl('https://outline.acme.example#top')).toBe(false) + }) + + it('refuses surrounding whitespace rather than trimming it', () => { + expect(isProviderUrl(' https://outline.acme.example')).toBe(false) + expect(isProviderUrl('https://outline.acme.example ')).toBe(false) + expect(isProviderUrl('https://outline acme.example')).toBe(false) + }) + + it('refuses anything that is not a URL at all', () => { + for (const value of ['', 'outline.acme.example', 'javascript:alert(1)', 'https://']) { + expect(isProviderUrl(value), `${value} was accepted`).toBe(false) + } + }) +}) + +describe('the link a guild hands out', () => { + it('is /g/{slug}/sign-in', () => { + expect(signInPath('acme')).toBe('/g/acme/sign-in') + }) + + it('joins onto an origin without doubling the slash', () => { + expect(signInUrl('https://sturnus.example', 'acme')) + .toBe('https://sturnus.example/g/acme/sign-in') + expect(signInUrl('https://sturnus.example/', 'acme')) + .toBe('https://sturnus.example/g/acme/sign-in') + }) + + it('sends the browser to the login endpoint with the guild in the query', () => { + // `GET /api/auth/login` reads no cookie -- there is no session yet, that + // is what login is for -- so the guild goes in the URL, which is the + // only place it can be before the round trip starts. + expect(loginUrl('acme')).toBe('/api/auth/login?guild=acme') + }) + + it('recognises its own shape back, so the allowlist and the builder agree', () => { + // Two files with one route shape between them are two shapes waiting to + // disagree, and the way they would disagree is that a link somebody + // handed out stops being treated as public and bounces its followers to + // a sign-in page they have no way to use. + expect(isGuildSignInPath(signInPath('acme'))).toBe(true) + expect(isGuildSignInPath('/g/acme/sign-in/')).toBe(true) + }) + + it('treats every slug as public, including the ones that are not slugs', () => { + // Deliberately looser than `slugProblem`. A middleware that sent a + // malformed name to the ordinary sign-in page and a registered one to + // the guild page would be a one-request oracle for which organisations + // use this service -- which is the disclosure the whole design refuses. + for (const path of ['/g/ACME/sign-in', '/g/1289374650912837465/sign-in', '/g/x/sign-in']) { + expect(isGuildSignInPath(path), `${path} would have needed a session`).toBe(true) + } + }) + + it('is not a licence for anything else under /g', () => { + for (const path of ['/g//sign-in', '/g/acme', '/g/acme/settings', '/sign-in', '/admin/queue']) { + expect(isGuildSignInPath(path), `${path} was treated as public`).toBe(false) + } + }) + + it('escapes a slug that somehow is not one', () => { + // Nothing this console writes can produce such a slug, and the API + // cannot store one. This is about the *read* path: a row that predates + // a rule, or a hand-edited database, must not put an unescaped path + // segment into an anchor. + expect(signInPath('a/../b')).toBe('/g/a%2F..%2Fb/sign-in') + expect(loginUrl('a&b=c')).toBe('/api/auth/login?guild=a%26b%3Dc') + }) +}) + +describe('what state a guild’s link is in', () => { + it('says there is no link when nothing is registered', () => { + const state = linkState(null) + expect(state.tone).toBe('absent') + expect(state.showLink).toBe(false) + }) + + it('says a registration without a secret is not yet a working link', () => { + // The state between step 2 and step 3 of the runbook. The link is real + // and published and answers exactly as a name nobody registered -- and + // an interface that drew it as "configured" would have somebody hand it + // out and lose an afternoon to it. + const state = linkState({ ...REGISTERED, hasSecret: false }) + expect(state.tone).toBe('incomplete') + expect(state.showLink).toBe(true) + expect(state.detail.key).toBe('admin.signInLink.stateIncompleteDetail') + }) + + it('says a registration with a secret is live, and names the slug', () => { + const state = linkState(REGISTERED) + expect(state.tone).toBe('live') + expect(state.detail.params?.slug).toBe('acme') + }) + + it('gives each state its own heading rather than one heading and a colour', () => { + const headings = [linkState(null), linkState({ ...REGISTERED, hasSecret: false }), linkState(REGISTERED)] + .map((state) => state.headingKey) + expect(new Set(headings).size).toBe(3) + }) +}) + +describe('what the registration form edits', () => { + it('has no field that could hold a credential', () => { + // The load-bearing assertion of this file. A secret field added "just + // for the add case" is how saving a change of base URL comes to clear a + // working credential. + const fields = Object.keys(emptyClientDraft()) + expect(fields).toEqual([ + 'slug', + 'provider', + 'baseUrl', + 'clientId', + 'redirectMode', + 'redirectUri', + ]) + for (const field of fields) { + expect(field.toLowerCase()).not.toContain('secret') + } + }) + + it('sends no credential in the body either', () => { + expect(Object.keys(clientDraftBody(ready())).sort()).toEqual([ + 'base_url', + 'client_id', + 'provider', + 'redirect_uri', + 'slug', + ]) + }) + + it('starts a guild on this deployment’s own callback', () => { + // `redirect_uri: null` is what nearly every guild wants: the console + // callback this deployment is already configured with. + expect(emptyClientDraft().redirectMode).toBe('default') + expect(clientDraftBody(emptyClientDraft()).redirect_uri).toBeNull() + }) + + it('sends a guild’s own callback when it has one', () => { + const body = clientDraftBody(ready({ redirectMode: 'custom', redirectUri: 'https://acme.example/cb' })) + expect(body.redirect_uri).toBe('https://acme.example/cb') + }) + + it('forgets a typed callback the moment the default is chosen again', () => { + // Otherwise a reader who ticks the box back would save a redirect URI + // that is no longer on screen. + const body = clientDraftBody(ready({ redirectMode: 'default', redirectUri: 'https://acme.example/cb' })) + expect(body.redirect_uri).toBeNull() + }) + + it('reads an existing registration back into a draft, both ways round', () => { + expect(clientDraftOf(REGISTERED).redirectMode).toBe('default') + expect(clientDraftOf({ ...REGISTERED, redirectUri: 'https://acme.example/cb' })).toMatchObject({ + redirectMode: 'custom', + redirectUri: 'https://acme.example/cb', + }) + }) + + it('does not rewrite the provider of a registration it does not understand', () => { + // The `directory.ts` rule about an unresolved snowflake, applied to a + // word: a form that hard-coded the constant into the body would + // silently re-register somebody else's provider as Outline. + expect(clientDraftBody(clientDraftOf({ ...REGISTERED, provider: 'keycloak' })).provider).toBe('keycloak') + }) + + it('does not trim a slug or a base URL on the way out', () => { + // Nothing on this path normalises. The value stored is the value typed, + // because the link an administrator hands out carries whichever of them + // they typed. + const body = clientDraftBody(ready({ slug: ' acme ', baseUrl: ' https://a.example ' })) + expect(body.slug).toBe(' acme ') + expect(body.base_url).toBe(' https://a.example ') + }) + + it('trims the client id, which is pasted rather than read', () => { + expect(clientDraftBody(ready({ clientId: ' client-abc\n' })).client_id).toBe('client-abc') + }) +}) + +describe('what is wrong with a draft', () => { + it('finds nothing wrong with a complete one', () => { + expect(clientDraftProblems(ready())).toEqual([]) + expect(isClientDraftReady(ready())).toBe(true) + }) + + it('puts each complaint on the field it is about', () => { + // So the page can render it beside that field rather than as a list of + // grievances at the bottom that nobody can match to an input. + const problems = clientDraftProblems({ + slug: 'Acme', + provider: 'keycloak', + baseUrl: 'http://x.example', + clientId: '', + redirectMode: 'custom', + redirectUri: 'nonsense', + }) + expect(problems.map((problem) => problem.field)).toEqual([ + 'slug', + 'provider', + 'baseUrl', + 'clientId', + 'redirectUri', + ]) + }) + + it('objects to a provider this deployment cannot exchange with', () => { + // Storing one would produce a guild whose link is permanently and + // silently broken, which is why the API refuses it rather than + // accepting a value it might one day support. + expect(clientProblemFor(clientDraftProblems(ready({ provider: 'keycloak' })), 'provider')?.key) + .toBe('admin.signInLink.providerUnsupported') + }) + + it('says an empty base URL is empty rather than malformed', () => { + expect(clientProblemFor(clientDraftProblems(ready({ baseUrl: '' })), 'baseUrl')?.key) + .toBe('admin.signInLink.baseUrlEmpty') + expect(clientProblemFor(clientDraftProblems(ready({ baseUrl: 'http://x.example' })), 'baseUrl')?.key) + .toBe('admin.signInLink.urlShape') + }) + + it('checks a redirect URI only when the guild is supplying one', () => { + // The default is `null`, and a text box left blank beside a ticked + // "use this deployment's callback" is not a mistake to complain about. + expect(clientDraftProblems(ready({ redirectMode: 'default', redirectUri: '' }))).toEqual([]) + expect(clientProblemFor(clientDraftProblems(ready({ redirectMode: 'custom' })), 'redirectUri')?.key) + .toBe('admin.signInLink.redirectEmpty') + }) + + it('refuses values longer than the columns behind them', () => { + expect(isClientDraftReady(ready({ clientId: 'x'.repeat(513) }))).toBe(false) + expect(isClientDraftReady(ready({ baseUrl: `https://x.example/${'y'.repeat(2048)}` }))).toBe(false) + }) + + it('never complains that a name is taken', () => { + // It cannot know, and asking would be the oracle. Every name that is + // spelled like a slug passes here and is left to the API's 409. + for (const slug of ['acme', 'login', 'api', 'sturnus']) { + expect(isClientDraftReady(ready({ slug })), `${slug} was refused locally`).toBe(true) + } + }) +}) + +describe('the credential', () => { + it('says only whether one is stored', () => { + const state = clientSecretState(REGISTERED) + expect(state.stored).toBe(true) + expect(Object.keys(state)).toEqual(['stored', 'statusKey', 'actionKey', 'canClear']) + }) + + it('offers nothing to clear when there is nothing stored', () => { + expect(clientSecretState({ ...REGISTERED, hasSecret: false }).canClear).toBe(false) + }) + + it('calls it storing the first time and replacing afterwards', () => { + expect(clientSecretState({ ...REGISTERED, hasSecret: false }).actionKey) + .toBe('admin.signInLink.secretSet') + expect(clientSecretState(REGISTERED).actionKey).toBe('admin.signInLink.secretReplace') + }) + + it('refuses an empty box, so that emptiness cannot read as clearing', () => { + expect(canSubmitClientSecret('')).toBe(false) + expect(canSubmitClientSecret('s')).toBe(true) + }) + + it('refuses more than the column holds, rather than sending it to be refused', () => { + // `apiError` keeps only the status, so a 400 from the API is a refusal + // this console cannot explain. + expect(canSubmitClientSecret('x'.repeat(MAX_SECRET_LENGTH))).toBe(true) + expect(canSubmitClientSecret('x'.repeat(MAX_SECRET_LENGTH + 1))).toBe(false) + }) +}) + +describe('where the requests go', () => { + it('addresses the registration and its credential separately', () => { + // Two routes rather than a field, which is what makes "save the + // registration" a request that demonstrably cannot carry a credential. + expect(clientPath(GUILD)).toBe(`/guilds/${GUILD}/oauth-client`) + expect(clientSecretPath(GUILD)).toBe(`/guilds/${GUILD}/oauth-client/secret`) + }) + + it('escapes the guild id', () => { + expect(clientPath('../1')).toBe('/guilds/..%2F1/oauth-client') + }) +}) + +describe('when a request does not work', () => { + const failing = (status: number) => ({ status }) + + it('reads a 404 as "nothing is registered yet"', () => { + // The API gives one 404 to a guild with no client, a guild nobody + // administers and a guild that does not exist. The page only ever asks + // about guilds the viewer administers, so the reading it can act on is + // the first -- which is a state, not an error. + expect(isMissingRegistration(failing(404))).toBe(true) + expect(isMissingRegistration(failing(500))).toBe(false) + expect(isMissingRegistration(null)).toBe(false) + }) + + it('says a name is unavailable without saying why', () => { + // Whether it is held by another guild or reserved by the deployment is + // exactly what the API refuses to disclose, and a console that expanded + // the sentence would undo that from the outside. + const message = describeClientError(failing(409)) + expect(message).toEqual({ key: 'admin.signInLink.errorNameTaken' }) + expect(message.params).toBeUndefined() + }) + + it('tells an unreachable API apart from one that said no', () => { + expect(describeClientError(failing(0)).key).toBe('admin.signInLink.errorUnreachable') + expect(describeClientError(new Error('boom')).key).toBe('admin.signInLink.errorUnreachable') + }) + + it('carries an unexpected status as a string, not a quantity', () => { + // `503` is not a number of anything, and `say` would write a quantity + // with the locale's grouping. + expect(describeClientError(failing(503))).toEqual({ + key: 'admin.signInLink.errorStatus', + params: { status: '503' }, + }) + }) + + it('has a sentence for every status these five routes can answer', () => { + for (const status of [400, 401, 404, 409]) { + expect(describeClientError(failing(status)).key) + .not.toBe('admin.signInLink.errorStatus') + } + }) +}) diff --git a/console/test/signInClient.spec.ts b/console/test/signInClient.spec.ts new file mode 100644 index 0000000..9bb8625 --- /dev/null +++ b/console/test/signInClient.spec.ts @@ -0,0 +1,448 @@ +/** + * The two controls that stand between an administrator and a credential + * that decides who gets a session. + * + * The decisions themselves are pinned in `oauthClient.spec.ts`, which does + * not mount anything. What is left here is exactly the part that only a + * rendered component can be wrong about, and all of it is invisible in a + * screenshot: + * + * - **There is no input anywhere on the registration form that could carry + * a client secret**, and no request it emits could contain one. A + * password box added to that form later would render identically to a + * reviewer and would silently clear a working credential every time + * somebody corrected a client id. + * - **The secret control shows no value and no mask**, its box does not + * exist until somebody presses for it, and what was typed does not + * outlive the panel. A masked placeholder is a value — it says how long + * the credential is — and a value would have had to come from somewhere. + * - **Clearing is a separate, confirmed act**, never something an empty box + * can do. + * + * `stubAutoImports` is `requeuePanel.spec.ts`'s convention: Nuxt provides + * these globals, vitest does not, and stubbing them is cheaper than + * starting a Nuxt runtime for two components whose only dependency on one + * is `useId` and `useSay`. + */ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, nextTick, ref, watch } from 'vue' + +import GuildSignInPage from '../app/pages/g/[slug]/sign-in.vue' +import SignInClientForm from '../app/components/SignInClientForm.vue' +import SignInClientSecret from '../app/components/SignInClientSecret.vue' +import { + type ClientDraft, + type GuildOAuthClient, + emptyClientDraft, +} from '../app/utils/oauthClient' + +/** Nuxt auto-imports these; vitest runs without Nuxt. */ +function stubAutoImports() { + vi.stubGlobal('ref', ref) + vi.stubGlobal('computed', computed) + vi.stubGlobal('watch', watch) + vi.stubGlobal('nextTick', nextTick) + let ids = 0 + vi.stubGlobal('useId', () => `id-${++ids}`) + // `useSay` turns a decided sentence into words. What it renders is not + // what this file is about, so a message comes back as its key. + vi.stubGlobal('useSay', () => (value: unknown) => + value === null || value === undefined ? '—' : String((value as { key?: string }).key ?? value), + ) +} + +/** The templates call `$t`; the key comes back as itself. */ +const MOCKS = { $t: (key: string) => key } + +const CLIENT: GuildOAuthClient = { + guildId: '1289374650912837465', + slug: 'acme', + provider: 'outline', + baseUrl: 'https://outline.acme.example', + clientId: 'client-abc', + redirectUri: null, + hasSecret: false, + createdAt: null, + updatedAt: null, +} + +function ready(over: Partial = {}): ClientDraft { + return { + slug: 'acme', + provider: 'outline', + baseUrl: 'https://outline.acme.example', + clientId: 'client-abc', + redirectMode: 'default', + redirectUri: '', + ...over, + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('the registration form', () => { + it('has no password field, and no field named for a secret', () => { + // The load-bearing assertion. `ClientDraft` has nowhere to put a + // credential, and this is the check that the template did not grow a + // box that binds to something else and sends it anyway. + // + // Asserted over the inputs rather than over `html()`, because the + // template's own comments explain at length why there is no credential + // field here -- and a check that fails on being described is a check + // people delete. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + expect(form.findAll('input[type="password"]')).toHaveLength(0) + for (const input of form.findAll('input')) { + const named = `${input.attributes('id') ?? ''} ${input.attributes('name') ?? ''}` + expect(named.toLowerCase(), `${named} looks like a credential field`) + .not.toContain('secret') + } + }) + + it('submits a body with no credential in it', () => { + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: ready() }, + global: { mocks: MOCKS }, + }) + form.find('form').trigger('submit') + const draft = form.emitted('submit')?.[0]?.[0] as ClientDraft + expect(Object.keys(draft)).not.toContain('secret') + expect(Object.keys(draft)).not.toContain('clientSecret') + }) + + it('does not open shouting at a form nobody has typed in', () => { + // A blank registration is every complaint at once. Rendering them + // before anybody has touched a field is a form that reads as broken on + // arrival. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + expect(form.html()).not.toContain('admin.signInLink.slugEmpty') + expect(form.findAll('[aria-invalid="true"]')).toHaveLength(0) + }) + + it('refuses to submit a draft it has complaints about, and then shows them', async () => { + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + await form.find('form').trigger('submit') + expect(form.emitted('submit')).toBeUndefined() + expect(form.html()).toContain('admin.signInLink.slugEmpty') + }) + + it('complains about a capital in a sign-in name as soon as it is typed', async () => { + // Beside the field, while the reader is still in it -- because + // `apiError` keeps only a status, so a rule only the API checks is a + // rule whose reason nobody ever reads. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + const slug = form.findAll('input[type="text"]')[0]! + await slug.setValue('Acme') + expect(form.html()).toContain('admin.signInLink.slugShape') + }) + + it('never offers to check whether a name is free', async () => { + // The one property here that is a security decision rather than a + // preference: a console that could answer this would be an oracle for + // which organisations use the service, reachable by anybody who + // administers any guild anywhere. + // + // `useApi` is deliberately **not** stubbed in this file. A form that + // grew a lookup -- of a slug, of a guild, of anything -- would call it + // and throw on mount, so "this component makes no request" is asserted + // by every test here and stated by this one. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + // Two buttons, and they are Save and Cancel. A third control on this + // form is where an availability check would have to live. + const buttons = form.findAll('button') + expect(buttons).toHaveLength(2) + expect(buttons.map((button) => button.attributes('type'))).toEqual(['submit', 'button']) + + // Nothing it says is about a name being free. The rendered text is + // translation keys, so this is a check on the vocabulary this + // namespace has -- there is no sentence here to render one. + await form.findAll('input[type="text"]')[0]!.setValue('acme') + for (const word of ['available', 'taken', 'free', 'exists']) { + expect(form.text().toLowerCase(), `the form talks about a name being ${word}`) + .not.toContain(word) + } + expect(form.emitted()).not.toHaveProperty('lookup') + }) + + it('has no dropdown for a provider with one legal value', () => { + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: emptyClientDraft() }, + global: { mocks: MOCKS }, + }) + expect(form.findAll('select')).toHaveLength(0) + expect(form.html()).toContain('outline') + }) + + it('hides the callback box until a guild says it has one of its own', async () => { + // "" and null are the same value in a text box and different values in + // this API, so the default is a state rather than an empty string. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: ready() }, + global: { mocks: MOCKS }, + }) + expect(form.find('input[type="url"][id$="-redirect"]').exists()).toBe(false) + await form.find('input[type="checkbox"]').setValue(true) + expect(form.find('input[type="url"][id$="-redirect"]').exists()).toBe(true) + }) + + it('forgets a typed callback when the default is chosen again', async () => { + // Otherwise the box is gone from the screen and its value is still in + // the request, which is an interface disagreeing with itself. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: ready() }, + global: { mocks: MOCKS }, + }) + const box = form.find('input[type="checkbox"]') + await box.setValue(true) + await form.find('input[type="url"][id$="-redirect"]').setValue('https://acme.example/cb') + await box.setValue(false) + await form.find('form').trigger('submit') + const draft = form.emitted('submit')?.[0]?.[0] as ClientDraft + expect(draft.redirectMode).toBe('default') + expect(draft.redirectUri).toBe('') + }) + + it('starts over when it is reopened on another guild’s registration', async () => { + // Without this, switching servers with the panel open edits the first + // guild's values under the second guild's heading. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'change', initial: ready({ slug: 'acme' }) }, + global: { mocks: MOCKS }, + }) + await form.setProps({ initial: ready({ slug: 'other' }) }) + expect((form.findAll('input[type="text"]')[0]!.element as HTMLInputElement).value) + .toBe('other') + }) + + it('disables its buttons while a request is running rather than removing them', async () => { + // A control that unmounts itself when pressed drops the keyboard to the + // top of the document. + stubAutoImports() + const form = mount(SignInClientForm, { + props: { mode: 'register', initial: ready(), busy: true }, + global: { mocks: MOCKS }, + }) + for (const button of form.findAll('button')) { + expect(button.attributes('disabled')).toBeDefined() + } + }) +}) + +describe('the client secret', () => { + it('shows no value, and no mask standing in for one', () => { + // A mask is a value: it says how long the credential is and what its + // first characters are, and it promises a "show" button this API cannot + // serve. + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: { ...CLIENT, hasSecret: true } }, + global: { mocks: MOCKS }, + }) + expect(secret.findAll('input')).toHaveLength(0) + expect(secret.html()).not.toContain('•') + expect(secret.html()).not.toContain('*****') + expect(secret.html()).toContain('admin.signInLink.secretNeverShown') + }) + + it('has no box at all until somebody presses for one', async () => { + // An empty password box rendered beside a stored credential is an + // invitation to save the form and silently clear it, which is the exact + // failure the API split this onto its own route to prevent. + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: { ...CLIENT, hasSecret: true } }, + global: { mocks: MOCKS }, + }) + expect(secret.find('input[type="password"]').exists()).toBe(false) + await secret.findAll('button')[0]!.trigger('click') + expect(secret.find('input[type="password"]').exists()).toBe(true) + }) + + it('drops what was typed when the panel is closed', async () => { + // There is nothing to come back to -- the value cannot be read back + // from anywhere -- so a half-typed credential in a hidden input is only + // a credential sitting in the page for longer than anybody meant. + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: CLIENT }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[0]!.trigger('click') + await secret.find('input[type="password"]').setValue('sh-typed') + // Cancel is the second button inside the open form. + await secret.findAll('form button')[1]!.trigger('click') + expect(secret.html()).not.toContain('sh-typed') + + await secret.findAll('button')[0]!.trigger('click') + expect((secret.find('input[type="password"]').element as HTMLInputElement).value).toBe('') + }) + + it('drops what was typed when the answer comes back', async () => { + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: CLIENT }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[0]!.trigger('click') + await secret.find('input[type="password"]').setValue('sh-typed') + await secret.setProps({ client: { ...CLIENT, hasSecret: true } }) + expect(secret.html()).not.toContain('sh-typed') + }) + + it('drops what was typed when the guild changes under it', async () => { + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: CLIENT }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[0]!.trigger('click') + await secret.find('input[type="password"]').setValue('sh-typed') + await secret.setProps({ client: { ...CLIENT, guildId: '999' } }) + expect(secret.html()).not.toContain('sh-typed') + }) + + it('refuses to submit an empty box, so emptiness cannot read as clearing', async () => { + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: { ...CLIENT, hasSecret: true } }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[0]!.trigger('click') + expect(secret.find('form button[type="submit"]').attributes('disabled')).toBeDefined() + await secret.find('input[type="password"]').setValue('sh-1') + expect(secret.find('form button[type="submit"]').attributes('disabled')).toBeUndefined() + await secret.find('form').trigger('submit') + expect(secret.emitted('store')?.[0]).toEqual(['sh-1']) + }) + + it('offers nothing to clear when there is nothing stored', () => { + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: CLIENT }, + global: { mocks: MOCKS }, + }) + expect(secret.html()).not.toContain('admin.signInLink.secretClear') + expect(secret.html()).toContain('admin.signInLink.secretSet') + }) + + it('makes clearing a second, deliberate press', async () => { + // Irreversible in the strongest sense available: nothing anywhere can + // read back what was there to put it back. + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: { ...CLIENT, hasSecret: true } }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[1]!.trigger('click') + expect(secret.emitted('clear')).toBeUndefined() + expect(secret.html()).toContain('admin.signInLink.secretClearConfirmBody') + await secret.findAll('button')[0]!.trigger('click') + expect(secret.emitted('clear')).toHaveLength(1) + }) + + it('never has both panels open at once', async () => { + // "Type a new credential" and "throw the old one away" are opposite + // intentions, and a reader who has one of them in front of them should + // not be one mis-click from the other. + stubAutoImports() + const secret = mount(SignInClientSecret, { + props: { client: { ...CLIENT, hasSecret: true } }, + global: { mocks: MOCKS }, + }) + await secret.findAll('button')[1]!.trigger('click') + expect(secret.find('input[type="password"]').exists()).toBe(false) + }) +}) + +describe('the page a guild’s link points at', () => { + /** Nuxt's page-level auto-imports, plus stubs for the three components it + * renders. `useApi` stays unstubbed here too, and that is the assertion: + * this page performs no lookup at all. */ + function mountPage(slug: string) { + stubAutoImports() + vi.stubGlobal('definePageMeta', () => undefined) + vi.stubGlobal('useRoute', () => ({ params: { slug } })) + vi.stubGlobal('useHead', () => undefined) + vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) + return mount(GuildSignInPage, { + global: { + mocks: MOCKS, + stubs: { + SturnusMark: { template: '' }, + NuxtLink: { props: ['to'], template: '' }, + // The real one puts a value inside a sentence; what matters here + // is that the value reaches the page, so it renders the slot. + 'i18n-t': { props: ['keypath'], template: '

{{ keypath }}

' }, + }, + }, + }) + } + + it('sends the browser to the login endpoint with the slug in the query', () => { + // A plain anchor rather than a fetch: the OAuth flow is a navigation to + // another origin and back, and an XHR cannot follow it. + const page = mountPage('acme') + expect(page.find('a[href^="/api/auth/login"]').attributes('href')) + .toBe('/api/auth/login?guild=acme') + }) + + it('renders identically for a name nobody has registered', () => { + // **The security property of this page.** `/api/auth/login?guild=…` + // answers the same 404 with the same body to a name nobody holds, a + // name that is not a name, and a guild whose secret was never supplied + // -- so that an attacker walking a list of organisation names cannot + // tell "no such organisation here" from "one, half-configured". A page + // that rendered differently for a name it recognised would put that + // oracle back, in HTML, in front of anybody with no session at all. + const one = mountPage('acme').html().replaceAll('acme', 'SLUG') + const other = mountPage('zzzzzzzz').html().replaceAll('zzzzzzzz', 'SLUG') + expect(other).toBe(one) + }) + + it('renders identically for a name that is not spelled like one', () => { + const one = mountPage('acme').html().replaceAll('acme', 'SLUG') + const other = mountPage('ACME').html().replaceAll('ACME', 'SLUG') + expect(other).toBe(one) + }) + + it('escapes what it was handed rather than trusting it', () => { + // Nothing this console writes can produce such a slug and the API + // cannot store one. This is the read path: whatever is in the address + // bar reaches an anchor, so it is escaped on the way. + const page = mountPage('a&b') + expect(page.find('a[href^="/api/auth/login"]').attributes('href')) + .toBe('/api/auth/login?guild=a%26b') + }) + + it('offers the way back to this deployment’s own sign-in', () => { + // For somebody who followed a link they were not the intended reader + // of, and whose account lives in the deployment's own Outline. + expect(mountPage('acme').find('a[href="/sign-in"]').exists()).toBe(true) + }) +})