General Information
Abstract
The auth-guest service gives guest users access to a share without a full
OpenCloud account. When a share is created for a user of type
USER_TYPE_GUEST, the service issues a one-time guest link token; redeeming
that token exchanges it for a signed session cookie that authenticates the
guest.
When the link token is no longer valid, the guest can renew: the service issues a new link token together with a one-time PIN. The guest then either redeems the new link or exchanges the PIN for a fresh session. A new PIN (and the nonce that binds it to the browser) is only issued when the request is authorized by a session cookie for the share; without one the link token is rotated silently.
The unauthenticated endpoints are protected against brute-force and abuse: the
PIN is bound to a browser-held nonce, failed PIN attempts back off exponentially
and invalidate the PIN after a maximum number of attempts, and renew is
limited per share (cooldown and daily cap), with separate quotas for renewals
with and without a valid session. The magic link is never affected by a PIN
lockout.
It is disabled by default. Set OC_ENABLE_GUEST_LINKS=true to enable the guest
links feature and start the service.
Table of Contents
Overview
- Consumes the share lifecycle events
ShareCreated,ShareRemoved,ShareExpired,SpaceShared,SpaceUnsharedandSpaceMembershipExpired. - Publishes the
GuestTokenCreatedevent (initial token) and theGuestTokenRenewedevent (refresh, carrying the new token and, when authorized by a session, the PIN). - Exposes unauthenticated endpoints to redeem a token, renew it and verify a
PIN; redeeming a token or verifying a PIN sets a session cookie.
renewandverify/pinare rate-limited. - Stores only hashes of the token secret, of the PIN and of the nonce, and deletes the stored record when the share is removed or expires.
Guest links flow
The following sequence diagram describes the guest links flow:
Renewal and PIN flow
A guest session (signed JWT) is valid for 24 hours by default
(AUTH_GUEST_JWT_TTL); a link token is single-use and valid for 30 minutes.
Verifying a token or a PIN returns the session expiry as expirationDateTime,
so the client can warn the guest before the session runs out, since it cannot
read the HttpOnly cookie.
Renewal starts from a failed attempt: redeem on a link that was already used
answers 409, and on an expired link 401; the client still holds the link
token.
renew requires the share id (permissionId). When the request carries a
session cookie with a valid, unexpired session token for that share, the service
validates the share, generates a new link token and a 6-digit PIN (30 minutes by
default), overwrites the stored token, PIN and nonce hashes (invalidating the
previous link and PIN), resets the redeemed flag, publishes GuestTokenRenewed
carrying the PIN, and sets the nonce cookie that binds the PIN to the requesting
browser.
Without a valid session (no cookie, an expired session token or one for
another share) the renewal is silent: the service validates the share,
rotates the stored token secret and expiry, resets the redeemed flag so the new
link can be redeemed, and publishes GuestTokenRenewed without a PIN; it
answers 200 and sets no nonce cookie. Because such a request is
unauthenticated, it is rate-limited per share.
While the newest link can still be redeemed (it is neither used nor expired), a
renewal without a valid session does nothing: it answers 200 like a real
renewal, but does not rotate the link, publish an event or count against the
quota. This path only needs the share id, so otherwise anyone who knows it
could cancel the link the guest just received. Renewals with a valid session
always issue a new link and PIN.
Both paths require an existing token record for the share; if publishing fails, the previous record is restored.
renew is limited per share, with a separate quota for each path so that
neither can use up the other's:
- Without a valid session: a cooldown between two renewals
(
AUTH_GUEST_QUOTA_COOLDOWN, default 1 minute) and a daily cap (AUTH_GUEST_QUOTA_DAILY_CAP, default 50 perAUTH_GUEST_QUOTA_WINDOW, default 24 hours). A link is only replaced once it is used or expired, so without anyone using a link there are at most 48 renewals a day. The default cap stays above that, so that someone who only knows the share id can't use it up and lock the guest out. - With a valid session: a cooldown (
AUTH_GUEST_SESSION_QUOTA_COOLDOWN, default 5 minutes) and a daily cap (AUTH_GUEST_SESSION_QUOTA_DAILY_CAP, default 5 perAUTH_GUEST_SESSION_QUOTA_WINDOW, default 24 hours). Each of these renewals issues a new PIN with fresh attempts, so this quota also limits how many PINs someone holding a stolen session can guess. It is sized for refreshing a session before it expires, plus a few retries.
A request within the cooldown or over the cap is rejected with 429 and a
Retry-After header.
To turn the new credentials into a session the guest either redeems the link
(verify/token) or enters the PIN (verify/pin); both return a session
cookie and the permissionId. verify/pin does not consume the link token, but
it requires the nonce cookie set by renew, so the PIN can only be verified
from the browser that requested the renewal. Each wrong PIN increments a counter
and the wait between attempts grows exponentially; after
AUTH_GUEST_BACKOFF_MAX_ATTEMPTS (default 5) failures the PIN is invalidated.
While the backoff is active verify/pin answers 429 with a Retry-After
header. The magic link (verify/token) is not affected by any of this: it
remains usable even when the PIN is locked out.
Token lifecycle
- Issue — on the consumed
ShareCreatedevent, where the grantee is a guest, the service generates a random secret and stores a record keyed by the hash of the share id. It then publishes theGuestTokenCreatedevent with the token. - Redeem — the guest posts the token to
POST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/token. The service validates the token and the share, marks the token as used and returns a signed JWT session token in a cookie plus the share'spermissionIdin the response body. Tokens are single-use and valid for 30 minutes; the session cookie lives for 24 hours by default (AUTH_GUEST_JWT_TTL). - Renew — the guest posts the
permissionIdtoPOST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew. If the request carries a session cookie with a valid, unexpired session token for that share, the service validates the share, generates a new link token and a 6-digit PIN (30 minutes by default), overwrites the stored token, PIN and nonce hashes (invalidating the previous link and PIN), resets the redeemed flag, publishesGuestTokenRenewedwith the PIN and sets the nonce cookie. Without a session the renewal is silent: the service validates the share, rotates the stored token secret and expiry, resets the redeemed flag and publishesGuestTokenRenewedwithout a PIN; no nonce cookie is set. Each path is limited per share by its own quota (cooldown plus daily cap), and an exceeded limit fails with429. Without a valid session nothing happens while the newest link is neither used nor expired. Both paths require an existing record; otherwise the request fails withtokenNotFound. If publishing fails, the previous record is restored. - Verify PIN — the guest posts the PIN, the
permissionIdand the nonce cookie toPOST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/pin. The service validates the nonce, the record and the share, compares the PIN against the stored argon2id hash and, on success, clears the PIN (single-use) and returns a signed JWT session token in a cookie plus the share'spermissionId. Each wrong PIN increments a counter and increases the wait before the next attempt; after the maximum number of failed attempts the PIN is invalidated and while the backoff is active the endpoint answers429. Verifying the PIN does not consume the magic link.
Endpoints
All error responses have the body
{ "errorType": "<type>", "message": "<msg>", "permissionId": "<share-id>" }.
Rate-limited responses (429) also carry a retryAfter field (seconds) and a
Retry-After header.
POST .../guestLinks/verify/token
- Request body:
{ "token": "<token>" } - Success:
200, session cookie,{ "permissionId": "<share-id>" } - Errors:
400 invalidRequest,401 tokenInvalid,401 tokenExpired,404 tokenNotFound,409 tokenAlreadyRedeemed,404 shareNotFound,410 shareExpired,500 internalError
POST .../guestLinks/renew
- Request body:
{ "permissionId": "<share-id>" } - Authorization: the session cookie, when it carries a valid, unexpired session token for the given share; without it the renewal is silent (no PIN, no nonce cookie)
- Success:
200, no session cookie. With a valid session a new link and PIN are sent by mail and the nonce cookie is set. Without one a new link is sent by mail, unless the newest link can still be redeemed, in which case nothing happens; the response is the same either way. - Errors:
400 invalidRequest,404 tokenNotFound,404 shareNotFound,409 conflict,410 shareExpired,429 tooManyRequests(withRetry-After),503 serviceUnavailable,500 internalError
POST .../guestLinks/verify/pin
- Request body:
{ "pin": "<pin>", "permissionId": "<share-id>" } - Required cookie: the nonce cookie set by
renew - Success:
200, session cookie, clears the nonce cookie,{ "permissionId": "<share-id>" } - Errors:
400 invalidRequest(missing nonce cookie),401 nonceInvalid,401 nonceExpired,401 pinInvalid,401 pinExpired,404 tokenNotFound,404 shareNotFound,410 shareExpired,429 tooManyRequests(withRetry-After),500 internalError
Frontend integration
The three endpoints are unauthenticated and rely on cookies, so the client must
send credentials with every request (for example credentials: 'include' in
fetch). Cross-origin deployments also need the service CORS to allow the
origin and credentials; the default AUTH_GUEST_CORS_ALLOW_ORIGINS of * is
not sufficient together with credentials, so set a concrete origin.
The PIN is bound to the browser that renewed, so the order matters:
renew— sets the nonce cookie on the response when a session authorized the renewal (which issues the PIN); a silent renewal sets no nonce cookie.verify/pin— the browser sends the nonce cookie back automatically; a successful response clears it and sets the session cookie.
The nonce cookie is HttpOnly, Secure, Path=/, SameSite=Lax and uses the
__Host- prefix, so it is only sent over HTTPS (browsers treat localhost as
secure) and cannot be read from JavaScript. It is scoped per share (a short
share-id hash is appended to the name), so multiple guest shares can be renewed
in one browser without overwriting each other's nonce cookie.
Client-side handling of the relevant responses:
200fromrenewwithout a session — a new link may or may not have been sent; tell the guest to use the link from their newest mail.400 invalidRequest"The nonce cookie is missing from the request." — the browser has no nonce (it never renewed, or the cookie expired): callrenewagain beforeverify/pin.401 nonceInvalid/401 nonceExpired— the nonce does not match or has expired: callrenewagain to get a fresh PIN and nonce.401 pinInvalid— wrong PIN; the wait between attempts grows, so retry with a delay.401 pinExpired— the PIN expired; callrenewagain.404 tokenNotFound— no guest link exists for this share; it has to be invited again.409 conflict— the share's record was updated concurrently; retryrenew.429 tooManyRequests— too many failed attempts, or the renewal cooldown or cap is active; wait for theRetry-Afterheader (or theretryAfterfield) in seconds, then retry.
Configuration
The service is configured via AUTH_GUEST_* environment variables or a
auth-guest.yaml file.
Both transports are enabled by default: the service consumes and publishes events and serves the HTTP endpoints. Either transport can be disabled:
AUTH_GUEST_EVENTS_DISABLED=true— the service does not consume events (no tokens are created or cleaned up) andrenewcannot publish its event, so it responds with503 serviceUnavailable.AUTH_GUEST_HTTP_DISABLED=true— the service does not serve the HTTP endpoints (verify/token,renew,verify/pin).
The link token lifetime is fixed (30 minutes). The lifetime of the one-time PIN and of the nonce that protects it is shared and configurable. The rate-limiting and nonce options are:
AUTH_GUEST_NONCE_COOKIE_BASE_NAME(default__Host-oc_guest_nonce; a short share-id hash is appended per share, e.g.__Host-oc_guest_nonce_<hash>) — base name of the nonce cookie set byrenewand required byverify/pin.AUTH_GUEST_PIN_AND_NONCE_TTL(default30m) — lifetime of the one-time PIN and of the nonce that binds it to the browser.AUTH_GUEST_BACKOFF_MAX_ATTEMPTS(default5),AUTH_GUEST_BACKOFF_BASE(default1s),AUTH_GUEST_BACKOFF_CAP(default30s) — failed PIN attempts before the PIN is invalidated, and the exponential backoff between attempts.AUTH_GUEST_QUOTA_DAILY_CAP(default50),AUTH_GUEST_QUOTA_COOLDOWN(default1m),AUTH_GUEST_QUOTA_WINDOW(default24h) — maximum renewals per share per window and the minimum time between two renewals (applies torenewwithout a valid session).AUTH_GUEST_SESSION_QUOTA_DAILY_CAP(default5),AUTH_GUEST_SESSION_QUOTA_COOLDOWN(default5m),AUTH_GUEST_SESSION_QUOTA_WINDOW(default24h) — the same forrenewwith a valid session, which issues a new PIN; counted separately.
Relevant options:
AUTH_GUEST_JWT_COOKIE_NAME,AUTH_GUEST_JWT_TTL— session cookie name and lifetime.- Guest session tokens are signed with a key derived from
OC_JWT_SECRET. There is no separate secret to configure; rotatingOC_JWT_SECRETinvalidates all guest sessions. AUTH_GUEST_TOKENS_STORAGE_ROOT— where guest link token records are stored.AUTH_GUEST_SERVICE_ACCOUNT_ID,AUTH_GUEST_SERVICE_ACCOUNT_SECRET— service account used to query the gateway for share metadata.AUTH_GUEST_NUM_CONSUMERS— number of concurrent event consumers.OC_REVA_GATEWAY— CS3 gateway used to look up shares.