Deploying · ADR
0007: Sign-in and protected projects
Context#
Protect the site at the edge puts a login in front of the whole origin. That is the simplest answer when everyone who may read one record may read all of them. It cannot express “the handbook is for employees, the rest is public”, or “SRE postmortems are for the SRE group only”: the proxy sees paths, not projects, and has nothing to hide from a signed-in user or to show to an anonymous one.
Some archives are mixed. A company publishes its architecture principles and its open RFCs, and keeps vendor negotiations, security reviews and an internal handbook behind a login. Those teams want one site, one URL, search that never leaks a title the reader may not open, and a sign-in that uses the identity provider they already have.
Decision#
The site can sign people in itself. You add an authentication option, in the same shape as
Zudoku’s, and decide what is protected: whole projects with
protected: true in their _project.yml, and any other paths with protectedRoutes.
Because static hosting cannot tell visitors apart, the build produces two sites:
dist/is the public site. Protected content is removed everywhere, not only hidden: the pages, the search index,llms.txtandllms-full.txt,decisions.json, RSS, the sitemap, the.mdcopy of every page and the related links of the records that remain. It is safe to publish anywhere.dist-protected/is the members’ site: everything, with a lock mark on protected records and an account menu in the header. It also holds_server.json(theauthenticationsettings without any secret,base,urlandprotectedRoutes), which tells the server what to enforce.
inkan serve is the small Node server that ties them together. It serves dist/ to anonymous visitors and
dist-protected/ to signed-in ones, and redirects an anonymous request for a protected page to sign-in. The check is
made on the server for every request, so there is nothing to bypass in the browser. A signed-in user who is not
allowed (see allow) gets a “no access” page and the public site.
Sign-in is OpenID Connect with the authorization code flow and PKCE (plain OAuth 2.0 for GitHub). The identity provider stays the source of truth; Inkan keeps no user database.
Edge or sign-in#
| At the edge | Sign-in (this record) | |
|---|---|---|
| Granularity | Whole site | Per project and per path; the rest stays public |
| Hosting | Any static host plus a proxy or the host’s access control | A Node process (inkan serve) |
| Search and agent files | Unreachable without a session | Public ones list public records only; members get the full ones |
| Who may enter | Whatever the proxy supports | allow: emails, domains, groups |
| Moving parts | A proxy or a dashboard setting; no change to Inkan | Config and one container |
Choose the edge when the answer is “all or nothing”. Choose sign-in when part of the archive is public or when
projects have different audiences. They combine: nothing stops you from running serve behind an edge proxy.
How to use it#
Configure#
import { defineConfig } from '@tilaven/inkan';
export default defineConfig({ url: 'https://decisions.acme.dev', authentication: { type: 'github', clientId: 'Iv1.0123456789abcdef', allow: { groups: ['acme'] }, }, protectedRoutes: ['/handbook/*'],});Mark a whole project protected in its _project.yml:
name: Securitydescription: Reviews and incident follow-ups.protected: trueinkan check reports project-protected when a project has protected: true but the config has no
authentication, because nobody could ever sign in to see it. Without authentication the options do nothing else:
the build is the usual single dist/.
| Option | Type | Default | What it does |
|---|---|---|---|
type | 'openid' | 'auth0' | 'entra' | 'gitlab' | 'github' | required | The identity provider. |
clientId | string | required | The OAuth application’s client ID. Not secret; INKAN_AUTH_CLIENT_ID overrides it. |
issuer | string | required for openid | The OpenID Connect issuer URL. |
domain | string | required for auth0 | The Auth0 tenant domain, e.g. acme.eu.auth0.com. |
tenantId | string | required for entra | The Microsoft Entra tenant. |
url | string | the public instance | Base URL of a self-managed GitLab or a GitHub Enterprise Server. |
scopes | string[] | openid email profile (GitHub: read:user user:email) | Scopes requested. |
allow | { emails?, domains?, groups? } | everyone who can sign in | Any one match lets a user in. Email and domain rules only count for an address the provider confirmed as verified (see trustEmail). Comparison is case-insensitive. |
trustEmail | boolean | false | Count email and domain rules even when the provider does not say whether the address is verified. For providers that never send email_verified; an address the provider flags as unverified still never counts. |
groupsClaim | string | groups (GitLab: groups_direct) | Claim that holds the user’s groups; dots reach into nested claims such as realm_access.roles. openid and auth0 only. |
maxAge | number (seconds) | 28800 (8 hours) | Session length; at least 60. |
redirectToAfterSignIn | string | the page the user came from | Path to land on after signing in. |
redirectToAfterSignOut | string | / | Path to land on after signing out. |
Unknown keys are an error, so a misspelled option cannot silently weaken the setup.
Providers#
Register an OAuth application with the provider and use this callback URL, exactly:
<url><base>_auth/callback/https://decisions.acme.dev/_auth/callback/ (served from the root)https://acme.github.io/decisions/_auth/callback/ (base '/decisions/')The client secret never goes into the config; see Environment.
GitHub. Create an OAuth app (Settings > Developer settings). Groups are organisations: allow.groups: ['acme']
admits members of the acme organisation, and inkan then asks for the read:org scope on its own. For
organisations that restrict third-party apps, an owner must approve the app. For GitHub Enterprise Server set url.
authentication: { type: 'github', clientId: '...', allow: { groups: ['acme'] } }// GitHub Enterprise Serverauthentication: { type: 'github', url: 'https://github.acme.dev', clientId: '...', allow: { emails: ['ana@acme.dev'] } }GitLab. Create an application with the openid, profile and email scopes. Groups come from the
groups_direct claim, so allow.groups lists group paths such as acme/platform. For a self-managed instance set url.
authentication: { type: 'gitlab', clientId: '...', allow: { groups: ['acme/platform'] } }authentication: { type: 'gitlab', url: 'https://gitlab.acme.dev', clientId: '...', allow: { domains: ['acme.dev'] } }Microsoft Entra ID. Register an app, add the callback as a Web redirect URI and create a client secret.
tenantId is required; the issuer is derived from it. Entra sends no email_verified claim, so email and domain
rules need one of two things: the optional xms_edov claim (Token configuration > Add optional claim), which Inkan
reads as the verification flag, or trustEmail: true when every account in the tenant is yours anyway.
authentication: { type: 'entra', tenantId: '00000000-0000-0000-0000-000000000000', clientId: '...', allow: { domains: ['acme.com'] } }Auth0. Create a Regular Web Application and set its allowed callback URL. domain is the tenant domain. Auth0
sends groups only through a custom claim, so add a rule or action that writes them, and name the claim in groupsClaim.
authentication: { type: 'auth0', domain: 'acme.eu.auth0.com', clientId: '...', groupsClaim: 'https://acme.dev/groups', allow: { groups: ['docs'] } }Generic OpenID Connect. Keycloak, Okta, Google, Dex and anything else with a discovery document at
<issuer>/.well-known/openid-configuration.
authentication: { type: 'openid', issuer: 'https://keycloak.acme.dev/realms/acme', clientId: 'decisions', groupsClaim: 'realm_access.roles', allow: { groups: ['staff'] } }authentication: { type: 'openid', issuer: 'https://accounts.google.com', clientId: '...apps.googleusercontent.com', allow: { domains: ['acme.com'] } }An http:// issuer or provider URL is accepted only for localhost, 127.0.0.1 and [::1], which is enough for a
local Keycloak or Dex while you try things out. Everything else must be https://.
Verified addresses. An allow.emails or allow.domains rule is only as good as the address it checks, and on
a provider where anyone can register with any address, an attacker could type yours. GitHub (verified addresses from
the API), GitLab, Auth0 and Google say whether an address is verified, and Inkan counts email rules only for an
address they vouched for. A provider that says nothing at all is treated as unverified: either make it send
email_verified (Keycloak and Okta do, Dex does for most connectors) or set trustEmail: true to accept its
addresses as they are. Group rules are not affected.
What protectedRoutes matches#
Patterns are paths relative to base:
| Pattern | Protects |
|---|---|
/handbook | /handbook/ only |
/handbook/* | /handbook/ and everything below it |
/*/rfc/* | the rfc/ folder of every project |
/sre/adr/0004-* | /sre/adr/0004-... records |
* matches anything, slashes included. /x/* includes /x itself. A trailing slash and the .md copy of a page
do not matter: /handbook/, /handbook and /handbook.md are the same path. Matching ignores base and is
applied to URL-decoded paths.
Environment#
Secrets never go into the config or into dist-protected/; the server reads them from the environment.
| Variable | Required | What it is |
|---|---|---|
INKAN_AUTH_SECRET | yes | Random string of at least 32 characters; encrypts the session cookies. openssl rand -base64 48 makes one. |
INKAN_AUTH_CLIENT_SECRET | yes | The client secret of the OAuth application. |
INKAN_AUTH_CLIENT_ID | no | Replaces clientId from the config, so one build can run in several environments. |
INKAN_PUBLIC_URL | no | The address people open. Replaces url; the callback URL is derived from it. Must start with http:// or https://. |
PORT | no | Port to listen on. Default: 8080 (--port wins). |
BASE_PATH | no | Sub-path for a site built without authentication (--base wins). With sign-in the base recorded in _server.json at build time is used. |
The server refuses to start when a required variable is missing.
Docker#
The image builds both variants and serves them with inkan serve. Compared with the nginx image,
the last stage keeps Node, because the server is what decides who sees what.
FROM node:22-alpineWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildENV NODE_ENV=productionEXPOSE 8080CMD ["node", "node_modules/@tilaven/inkan/bin/cli.mjs", "serve"]This is the Dockerfile of the nginx route with one stage and a different last line. init already lists dist,
dist-protected and node_modules in .dockerignore and .gitignore, so they are rebuilt inside the image. node
runs as PID 1 so docker stop reaches the server, which shuts down cleanly on SIGTERM. npm run build is inkan build, which writes dist/, dist-protected/ and
dist-protected/_server.json; the server needs those and node_modules at run time, and nothing else (it does not
read the config or the Markdown). Single-stage keeps it simple. If image size matters, copy dist, dist-protected,
package*.json and a production-only node_modules into a second stage.
docker build -t decisions .docker run --rm -p 8080:8080 \ -e INKAN_AUTH_SECRET="$(openssl rand -base64 48)" \ -e INKAN_AUTH_CLIENT_SECRET=... \ -e INKAN_PUBLIC_URL=http://localhost:8080 \ decisionsPut TLS in front (a load balancer or an ingress). Cookies are marked Secure when the public URL is https://, and
the OAuth providers require https:// callbacks anyway outside localhost.
Try it locally#
inkan devshows everything, with lock marks on protected records; no sign-in is involved.inkan dev --publicshows what an anonymous visitor sees, so you can check that nothing leaks.- To try the real flow, build and run the preview with the secrets in the environment.
previewnoticesdist-protected/_server.jsonand starts the sign-in server onhttp://localhost:4321instead of Astro’s static preview:
inkan buildINKAN_AUTH_SECRET="$(openssl rand -base64 48)" \INKAN_AUTH_CLIENT_SECRET=... \INKAN_PUBLIC_URL=http://localhost:4321 \inkan previewRegister http://localhost:4321/_auth/callback/ with the provider (GitHub and most providers accept localhost over
http).
Routes#
inkan serve owns these paths under <base>_auth/, which is why they are reserved:
| Route | What it does |
|---|---|
_auth/login/ | Starts sign-in; ?redirect= says where to land afterwards. |
_auth/callback/ | Where the provider sends the user back. |
_auth/logout/ | Clears the session. POST only, from the site’s own sign-out form: the request’s Origin must be the site (the Host the request came to, or the public url / INKAN_PUBLIC_URL, for a proxy that rewrites Host), so a link or a form on another site cannot sign people out. A GET answers 405. |
_auth/me/ | JSON with the signed-in user, for the account menu. |
_auth/denied/ | The page shown when sign-in fails or the user is not allowed. |
Consequences#
- One site can mix public and members-only content, per project or per path, with a single sign-in.
- Hosting changes: the public site alone is static, but sign-in needs the Node server (
inkan serve, container or any Node host). GitHub Pages and GitLab Pages cannot enforce it; use them with the edge route or only for the publicdist/. - What is protected. Pages,
.mdcopies, search, the decision graph (/graph/,/graph.json),llms.txt,llms-full.txt,decisions.json, RSS and the sitemap of the public site contain no protected content. The public build does not load protected projects at all, so the images their records embed are never copied intodist/_astro/; after the build, every file of a protected route goes whatever its type (a plugin’s JSON, a generated image), and so does every hashed asset under_astro/that no remaining file references, which also covers the images of pages matched byprotectedRoutes.inkan notifyleaves protected records out as well: a chat channel is no members-only place. Related, superseded and tag links to protected records are dropped from public pages and from the decision graph, however the reference is written (any case, with or without.mdor slashes). Links written by hand in the body of a record are not rewritten: a public record that links to a protected one keeps the link, and an anonymous reader following it is sent to sign-in. Avoid such links, or make the linking record protected too. The existence of a title can also leak through the link text itself. - Lock marks in the members’ site show which records the public cannot see, so authors do not paste them into public places by accident.
- Caching. The server sends
Vary: Cookieon every response, andCache-Control: private, no-cachefor signed-in users. A CDN in front must respectVary: Cookieor be bypassed for this origin, otherwise it may serve the members’ page to an anonymous visitor. - Search. The members’ site has a search index with everything, the public one has the public records only.
External providers (
algolia,meilisearch,typesense) getsearch.jsonfrom the public variant, so protected records are never pushed to a service whose key lives in the browser. Members then do not find protected records through that provider. - Agents and exports.
inkan exportandinkan mcpread the Markdown on disk, so they see protected records; run them only where the files are already trusted. A remotemcp --urlsees what an anonymous visitor sees. - Signing out is a
POSTto_auth/logout/from the form in the account menu, never a link: aGETis answered with 405, so a bookmark or a link to that path does nothing, and a request whoseOriginis not the site is refused with 403 and keeps the session. Behind a proxy that rewritesHost, seturlorINKAN_PUBLIC_URLto the address users open, which sign-in needs anyway. - Email rules and verification.
allow.emailsandallow.domainsadmit a user only when the provider confirmed the address (email_verified, GitHub’s verified addresses, Entra’sxms_edov). A provider that sends no such claim admits nobody through these rules untiltrustEmail: trueis set; group rules are unaffected. This is deliberate: on a provider where anyone can register with any address, an unverified address proves nothing. - Sessions. The session is an encrypted cookie that carries the user’s id, name, email and picture. The server keeps
no session state, so it scales horizontally and a restart signs nobody out. Changing
INKAN_AUTH_SECRETsigns everybody out at once, which is also how you revoke sessions in an emergency. Access is checked when signing in, not on every request: a user removed from a group keeps access until the session expires (maxAge, 8 hours by default), so lower it when that matters. - Publishing.
dist/alone is safe to publish anywhere.dist-protected/contains every protected page and must never be uploaded to a static host or served by anything other thaninkan serve._server.jsonholds no secret, but the folder as a whole is as sensitive as the records. - The identity provider and the Node process join the availability and security story of the archive: keep the package updated and the secrets in a secret store.
History 1
- ImplementedInkan maintainers
9e5e535commit diff