Inkan
↑↓ move↵ open
GitHub

Deploying · ADR

0007: Sign-in and protected projects

Date

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.txt and llms-full.txt, decisions.json, RSS, the sitemap, the .md copy 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 (the authentication settings without any secret, base, url and protectedRoutes), 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 edgeSign-in (this record)
GranularityWhole sitePer project and per path; the rest stays public
HostingAny static host plus a proxy or the host’s access controlA Node process (inkan serve)
Search and agent filesUnreachable without a sessionPublic ones list public records only; members get the full ones
Who may enterWhatever the proxy supportsallow: emails, domains, groups
Moving partsA proxy or a dashboard setting; no change to InkanConfig 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#

inkan.config.ts
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:

decisions/security/_project.yml
name: Security
description: Reviews and incident follow-ups.
protected: true

inkan 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/.

OptionTypeDefaultWhat it does
type'openid' | 'auth0' | 'entra' | 'gitlab' | 'github'requiredThe identity provider.
clientIdstringrequiredThe OAuth application’s client ID. Not secret; INKAN_AUTH_CLIENT_ID overrides it.
issuerstringrequired for openidThe OpenID Connect issuer URL.
domainstringrequired for auth0The Auth0 tenant domain, e.g. acme.eu.auth0.com.
tenantIdstringrequired for entraThe Microsoft Entra tenant.
urlstringthe public instanceBase URL of a self-managed GitLab or a GitHub Enterprise Server.
scopesstring[]openid email profile (GitHub: read:user user:email)Scopes requested.
allow{ emails?, domains?, groups? }everyone who can sign inAny 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.
trustEmailbooleanfalseCount 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.
groupsClaimstringgroups (GitLab: groups_direct)Claim that holds the user’s groups; dots reach into nested claims such as realm_access.roles. openid and auth0 only.
maxAgenumber (seconds)28800 (8 hours)Session length; at least 60.
redirectToAfterSignInstringthe page the user came fromPath to land on after signing in.
redirectToAfterSignOutstring/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 Server
authentication: { 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:

PatternProtects
/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.

VariableRequiredWhat it is
INKAN_AUTH_SECRETyesRandom string of at least 32 characters; encrypts the session cookies. openssl rand -base64 48 makes one.
INKAN_AUTH_CLIENT_SECRETyesThe client secret of the OAuth application.
INKAN_AUTH_CLIENT_IDnoReplaces clientId from the config, so one build can run in several environments.
INKAN_PUBLIC_URLnoThe address people open. Replaces url; the callback URL is derived from it. Must start with http:// or https://.
PORTnoPort to listen on. Default: 8080 (--port wins).
BASE_PATHnoSub-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.

Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8080
CMD ["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.

Terminal window
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 \
decisions

Put 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 dev shows everything, with lock marks on protected records; no sign-in is involved.
  • inkan dev --public shows 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. preview notices dist-protected/_server.json and starts the sign-in server on http://localhost:4321 instead of Astro’s static preview:
Terminal window
inkan build
INKAN_AUTH_SECRET="$(openssl rand -base64 48)" \
INKAN_AUTH_CLIENT_SECRET=... \
INKAN_PUBLIC_URL=http://localhost:4321 \
inkan preview

Register 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:

RouteWhat 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 public dist/.
  • What is protected. Pages, .md copies, 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 into dist/_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 by protectedRoutes. inkan notify leaves 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 .md or 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: Cookie on every response, and Cache-Control: private, no-cache for signed-in users. A CDN in front must respect Vary: Cookie or 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) get search.json from 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 export and inkan mcp read the Markdown on disk, so they see protected records; run them only where the files are already trusted. A remote mcp --url sees what an anonymous visitor sees.
  • Signing out is a POST to _auth/logout/ from the form in the account menu, never a link: a GET is answered with 405, so a bookmark or a link to that path does nothing, and a request whose Origin is not the site is refused with 403 and keeps the session. Behind a proxy that rewrites Host, set url or INKAN_PUBLIC_URL to the address users open, which sign-in needs anyway.
  • Email rules and verification. allow.emails and allow.domains admit a user only when the provider confirmed the address (email_verified, GitHub’s verified addresses, Entra’s xms_edov). A provider that sends no such claim admits nobody through these rules until trustEmail: true is 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_SECRET signs 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 than inkan serve. _server.json holds 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

  1. ImplementedInkan maintainers9e5e535 commit diff