IIIF Authorization Flow API 2.0 (and 1.0)¶
The contract iiiris maintains for IIIF auth: which services it
hosts, which interaction patterns it supports, which access-service
backends are pluggable, and the invariants that hold across all
combinations. This spec replaces docs/briefs/IIIF_AUTH_FLOW2.md
now that the work has shipped and stabilised.
The operator-facing reference lives in
../iiif-auth.md — YAML schema, viewer
compatibility, setup walkthroughs. This spec captures what the
implementation guarantees; that file captures how to use it.
References: - IIIF Authorization Flow API 2.0 - IIIF Authentication API 1.0
Surface¶
Services (all four, both API versions)¶
AuthProbeService2—GET /iiif/auth/probe/{identifier...}. Required by spec. Viewers call with a Bearer token (or none) and receive anAuthProbeResult2with status / heading / substitute / redirect. Status codes: 200 (full access), 401 (substitute or hard deny), 302 (redirect to access service).AuthAccessTokenService2—GET /iiif/auth/token. The postMessage iframe flow: iiiris responds with an HTML page that posts an opaque Bearer token back to the viewer origin. Origin is allowlist-checked (see CORS contract below).AuthAccessService2—GET /iiif/auth/login/{profile}. The built-in access service. Hosts a clickthrough / login UI by default; can be replaced by an external URL per profile (see Access-service backends).AuthLogoutService2—POST /iiif/auth/logout. Invalidates the server-side session, clears the cookie. Advertised on the access service per spec.
Auth 1.0 dual-advertisement¶
Every service has a 1.0 equivalent under /iiif/auth/v1/.... When
a gated image's info.json is rendered, both v1 and v2 service
blocks are emitted side-by-side. Routing dispatches by URL prefix;
the underlying authorization decision is identical — only the
response shape changes per spec version.
The dual-advertisement is the migration-compatibility commitment (matching the Image API v2 + v3 stance): viewers that only support 1.0 still work; viewers that prefer 2.0 use 2.0.
Routes¶
All auth endpoints live under /iiif/auth/ so the surface is
visually co-located with the IIIF tree:
/iiif/auth/probe/{identifier...} AuthProbeService2
/iiif/auth/token AuthAccessTokenService2 (postMessage)
/iiif/auth/login/{profile} built-in access service UI
/iiif/auth/callback/{profile} external SSO callback (OIDC / external)
/iiif/auth/logout AuthLogoutService2
/iiif/auth/v1/... 1.0 equivalents
{identifier...} uses the same {rest...} catchall as the IIIF
image / info handlers; probe URLs carry arbitrarily-shaped
identifiers without escaping concerns.
Interaction patterns¶
All four IIIF Auth 2.0 patterns are supported. The pattern is a
property of an auth profile and is advertised as the access
service profile in info.json.
active— interactive credential login. The viewer opens the access service in a window; the user enters credentials; iiiris mints a session.clickthrough— terms-of-use accept. The access service shows operator-configured copy; the user clicks accept; iiiris mints a session. No credentials.kiosk— automatic / IP / cookie-based access. The access service immediately grants if the request meets the kiosk condition (typically an IP allowlist enforced via the access service's backend).external— ambient signal from a reverse proxy. The pattern signals the viewer that login happens outside iiiris (e.g. an oauth2-proxy in front). iiiris reads the trusted header and trusts it.
Access-service backends¶
Four backends, selectable per profile. All four implement the same interface; the rule engine and probe handler are backend-agnostic.
builtin— iiiris hosts the login UI. Clickthrough renders an accept page; credential auth verifies against an htpasswd-style user list (bcrypt + apr1 supported). Users configurable inline under the profile (users:map) or via anhtpasswd_file:on disk (file wins if both are set; mtime-watched and reloaded without restart).header— trust a reverse-proxy header (X-Forwarded-User,X-Auth-Request-Email, etc.). No in-process auth dance; the trusted header is the session identifier. Requiresauth.trusted_proxiesso iiiris ignores the header from non-proxy peers.external— generic redirect-to-URL with a signed callback. For deployments with a bespoke login service. iiiris redirects to the configured URL; the callback validates a signed token and mints a session.oidc— first-class OpenID Connect client (viacoreos/go-oidc). iiiris runs the auth-code flow itself (issuer discovery, ID-token validation, claims mapping) and mints its own session on success.
Decision seam¶
Authorization flows through two layers:
auth.Authorizer— the runtime. Owns YAML rule evaluation (prefix/glob match → profile → pattern resolution), session lookup, token validation, and the default allow/deny verdict.hook.Hook.Authorize— optional override. If a hook is configured, its decision can refine the Authorizer's verdict — substitute a different identifier, swap a redirect target, or harden a soft-deny into a hard-deny. A hook cannot lift a rule-driven deny — that boundary is enforced inserveImageand is load-bearing for the security model.
The shared decision type:
type AuthDecision struct {
Allow bool // gate verdict
Reason string // diagnostic; surfaced in admin decision log
Profile string // hook-selected profile; empty = use rule match
Substitute string // alternate identifier; empty = serve original
Redirect string // 303 target; empty = no redirect
}
All hook engines (Lookup, Lua, Webhook) consume the same
struct. Noop returns {Allow: true} and the rule-driven verdict
stands.
Rule engine¶
YAML declares profiles (the auth shape) and rules (which identifiers use which profile):
- Rules are evaluated in order; first match wins. No rule means the identifier is public.
- Match grammar is prefix/glob:
restricted/*,images/staff/*, exactsecret-photo.jpg. Operators who need richer matching configure a hook;Hook.Authorizeruns after rule evaluation and can refine. - A rule references a profile by name. Profile shape (pattern, backend, TTLs, substitute policy, etc.) lives in the profile block; rules only select.
- Public images bypass auth entirely. Their
info.jsoncarries no auth service blocks; their requests skip the probe / token machinery. Performance for ungated content is unchanged from pre-auth-subsystem behaviour.
Tokens¶
- Format: opaque, 32 random bytes, base64url-encoded. Server-side lookup; iiiris owns session state.
- No JWT, no signing knob, no
token_strategy:config. Opaque tokens cover every use case in the spec; adding alternate formats later is non-breaking and not worth pre-reserving config surface for. - TTL: global defaults under
auth:(5 m token, 24 h session per spec examples). Per-profile values override.
Session storage¶
The session.Store interface has four implementations. Operators
pick the right one for their deployment topology:
HeapStore— in-memory. Default. Single-instance only; sessions evaporate on restart. Suitable for development and single-host kiosk deployments.FilesystemStore— on-disk JSON files. Single-instance, survives restart. Suitable for single-host production where Redis is overkill.S3Store— S3 (or S3-compatible) object store. Multi-host capable in principle but with eventual-consistency caveats; not the recommended path for active-active deployments.RedisStore— Redis. The only multi-replica-safe option. Required for active-active deployments where any iiirisd instance must be able to validate any session.
Optional Counter capability surfaced via session.AsCounter so
the admin UI's /admin/auth page can report active session counts
without coupling to a specific backend.
Substitute resources¶
A denied request can serve a degraded view instead of a hard 401. Three modes, per profile:
substitute.max_size: "!W,H"— built-in size degrade. The denied request is internally rewritten to the constrained IIIF size and served from the same source + pipeline. All denied users hit the samesubstitute-tier cache entry (see Cache contract); fast.Hook.Resolveoverride — return a fully different identifier (e.g. a curated low-res alternate in a separate bucket) for cases the static size-degrade rule doesn't cover. The substitute identifier flows through the normal pipeline.substitute: deny— opt out of substitutes entirely. The probe response isAuthProbeResult2with status 401 and no substitute. Hard wall.
Cache contract¶
RenderCache is keyed by (path, auth-tier) for gated
identifiers via auth.RenderKey(r.URL.Path, tier). InfoCache is
keyed similarly via auth.InfoKey(r.Host, r.URL.Path, tier) —
info.json varies per tier because the advertised service blocks
differ for anonymous vs authenticated callers.
Tier vocabulary¶
Capped at three values. Adding a fourth is treated as a cache-key version change.
public— image is not under any auth rule. Single shared key, identical to pre-auth behaviour. The unsalted legacy key shape is preserved on this tier so deployments that added auth rules don't re-render every public asset.full— request is authenticated and authorized for full resolution. Per-tier key salted with|v1|full.substitute— request was denied; a substitute is being served. Shared key across all denied users (they see the same degraded image). Salted with|v1|substitute.
Cache-key versioning¶
cacheKeyVersion in internal/auth/cachekey.go is the salt
version. Bumping it cleanly invalidates existing gated entries on
rollout — operators see an eviction wave but never serve a
stale-tier image to the wrong viewer.
The |v1| prefix in gated tiers is the current version. Adding a
new tier value (e.g. a per-group staff tier) requires bumping to
|v2|. The bump is deliberate friction: tier vocabulary changes
should not happen incidentally.
CORS contract¶
- Probe service: wildcard CORS
(
Access-Control-Allow-Origin: *) is the default and is acceptable because the probe response is purely informational — any reader can call it; only Bearer-token holders learn anything protected. Operators can narrow viaauth.cors.probe_origins: [...]if they prefer. - Access Token service: the postMessage flow requires an
origin-locked response per spec, so the token service uses an
allowlist:
auth.cors.token_origins: [...]. No default — operators must list their viewer origins.["*"]is permitted as an explicit opt-in for public deployments.
Cookie contract¶
- Name:
iiiris_session. - Attributes:
Secure,HttpOnly,SameSite=None(required for cross-origin viewer embedding). - Path: auto-derived from the incoming request — specifically
the value of
X-Forwarded-Prefixif present (the convention nginx / Traefik / oauth2-proxy use when iiiris is mounted under a path), else/. Noauth.cookie_pathconfig knob — the derivation is the contract. TheX-Forwarded-Prefixdependency is documented indocs/iiif-auth.mdanddocs/deployment.md.
Trusted proxies¶
Real-IP extraction (for header-backend trust and per-IP limiter)
honours X-Forwarded-For only when the immediate peer is in
auth.trusted_proxies (a CIDR list). Default empty — no proxy
trust unless declared. The CIDR parser is exposed as
auth.ParseCIDRs so the per-IP limiter middleware can validate
the same list even when the auth subsystem is otherwise
disabled.
info.json advertisement¶
- Each rule references a profile; each profile renders to v2 + v1
serviceblocks injected into the matching image's info.json. - Profiles not referenced by any rule are not emitted.
- For deployments whose profile choice is dynamic,
Hook.Authorizereturns anAuthDecision.Profilevalue; the hook selects a profile but does not hand-roll the service block. The YAML profile schema remains the single source of truth for service shape, so info.json stays validator-compliant regardless of who chose the profile.
Zero-config posture¶
- No
auth:block in YAML → auth subsystem is not initialized.AllowAllis wired, info.json carries no auth services, Redis is not imported into the dependency graph at startup. Pre-auth behaviour is preserved exactly. auth:present butrules:empty → subsystem up, nothing gated. Operators can flip on a single rule without a restart-shape change.
Admin surface¶
/admin/auth lists:
- Configured profiles (pattern, backend, label, user list shape).
- Configured rules (match → profile mapping).
- Active session counts (total + per-profile) via the optional
session.Countercapability. - Recent allow/deny decisions (last 64 from
auth.DecisionLog's ring buffer). - 24-hour rolling allow/deny totals.
The dashboard's /admin/ summary gains active-session +
24h-denials counters.
Test surface¶
Three layers of coverage:
- Unit tests in
internal/auth/...: rule evaluation, session storage backends, token generation + validation, htpasswd matching (bcrypt + apr1), real-IP extraction, cache-tier key generation, probe response shapes (1.0 + 2.0), substitute path rewriting. - Conformance harness (
tools/iiif-auth-conformance): a project-local harness that drives the four interaction patterns (clickthrough / active / kiosk / header) against a multi-profile iiirisd, asserts each response carries the correct@context,type, andstatus. Runs in CI via theiiif-auth-validatejob — load-bearing (noallow_failure); an auth regression fails the pipeline. When a community-blessed IIIF Auth validator emerges the harness is swapped in place.external+oidcbackends are out of scope for the conformance run (they require real upstream services); unit tests cover the iiirisd side of those contracts. - Reference-viewer matrix in
docs/iiif-auth.md: Mirador, Universal Viewer, Clover, each tagged with the patterns verified.
Out of scope¶
- Per-user rate limiting or quotas. Auth identifies users; quota enforcement is operator-side (edge proxy / CDN).
- Tamper-evident audit log of access decisions. Ordinary request logging + the admin decision log cover allow/deny; a dedicated audit trail is not in scope.
- Encryption at rest for the session store. Backend's own protections (disk encryption, S3 SSE, Redis ACLs) cover this.
- Federated multi-server SSO orchestration. iiiris is an SSO target, not an orchestrator across multiple iiiris instances.
- A general-purpose IdP. The built-in login covers
clickthrough + small static user lists; anything more goes
through external SSO (
oidc/external/header).