Skip to content

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)

  • AuthProbeService2GET /iiif/auth/probe/{identifier...}. Required by spec. Viewers call with a Bearer token (or none) and receive an AuthProbeResult2 with status / heading / substitute / redirect. Status codes: 200 (full access), 401 (substitute or hard deny), 302 (redirect to access service).
  • AuthAccessTokenService2GET /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).
  • AuthAccessService2GET /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).
  • AuthLogoutService2POST /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 an htpasswd_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. Requires auth.trusted_proxies so 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 (via coreos/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:

  1. auth.Authorizer — the runtime. Owns YAML rule evaluation (prefix/glob match → profile → pattern resolution), session lookup, token validation, and the default allow/deny verdict.
  2. 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 in serveImage and 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/*, exact secret-photo.jpg. Operators who need richer matching configure a hook; Hook.Authorize runs 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.json carries 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 same substitute-tier cache entry (see Cache contract); fast.
  • Hook.Resolve override — 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 is AuthProbeResult2 with 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 via auth.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.
  • 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-Prefix if present (the convention nginx / Traefik / oauth2-proxy use when iiiris is mounted under a path), else /. No auth.cookie_path config knob — the derivation is the contract. The X-Forwarded-Prefix dependency is documented in docs/iiif-auth.md and docs/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 service blocks 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.Authorize returns an AuthDecision.Profile value; 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. AllowAll is 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 but rules: 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.Counter capability.
  • 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, and status. Runs in CI via the iiif-auth-validate job — load-bearing (no allow_failure); an auth regression fails the pipeline. When a community-blessed IIIF Auth validator emerges the harness is swapped in place. external + oidc backends 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).