Problem
JSS currently authenticates via three paths: NIP-98 (Nostr-signed), Solid-OIDC DPoP, and simple Bearer tokens (see src/auth/middleware.js, src/auth/token.js, src/auth/nostr.js). All three assume the agent already controls a cryptographic key. We have no story for:
- Account recovery — user loses their Nostr key / OIDC credentials and is locked out of their pod. There's no out-of-band channel to prove pod ownership and restore access.
- Low-friction first-touch auth — a recipient of a shared link who doesn't yet have a WebID. Today they need to spin up an identity before they can interact, even for one-shot flows like dropping a file into someone's inbox.
- Time-bound delegated reads — sharing a private resource with a known contact for a window, without granting them a long-lived agent identity.
OTP (single-use, short-lived, channel-delivered) is the standard primitive for all three.
Proposed design
A new auth path alongside the existing ones — challenge / verify / session.
Issue endpoint (POST /.well-known/otp/request)
- Body:
{ identifier, purpose } where identifier is an email or a contact URI the pod owner has previously registered as a recovery channel, and purpose is one of recovery | session | claim-capability.
- Server generates a 6–8 digit code (or a longer base32 token for link delivery), stores
(hash(code), purpose, identifier, expiry, attempts_remaining) in a server-side OTP table, and dispatches via the channel adapter.
- Rate-limited per identifier and per source IP.
Verify endpoint (POST /.well-known/otp/verify)
- Body:
{ identifier, code, purpose }.
- On match: mark code used (single-use), and depending on
purpose:
recovery → mint a recovery session token bound to the WebID of the identifier's owner; that session can register a new key (e.g. a new Nostr pubkey in the WebID profile's authentication set).
session → mint a short-lived Bearer or DPoP token for normal auth.
claim-capability → mark a paired capability URI as activated (ties into the sibling cap-URI issue).
Channel adapters (pluggable)
- v1: email (SMTP/relay), since email is universally available.
- v2: Nostr DM to a registered recovery pubkey (uses keys other than the lost one).
- v3: Matrix / Signal-style messengers via a relay.
Storage
- New table/file
otp.json under the pod's private area, or a server-global OTP store keyed by (podId, identifier) for recovery flows where the pod's own storage may be inaccessible (e.g. corrupted ACL).
Use cases
- Account recovery: user lost their key → OTP to registered recovery email → mint recovery session → user adds new key to their WebID's
authentication set.
- Inbox / drop-box: stranger receives a capability URI from Alice, the URI requires an OTP confirmation step before write is honored → reduces spam and confirms the recipient controls the channel.
- Time-bound delegated read: Alice shares a resource with [email protected] → bob clicks the link → OTP to his email → 24h read session bound to that resource.
Security
- Codes hashed at rest (server never sees plaintext after generation).
- Strict rate limit per identifier and per IP; lockout after N failed attempts.
- Short expiry (5–10 min for codes, longer for one-click links).
- Single-use enforced atomically (check-and-mark in one DB operation).
- Recovery channel must be pre-registered and confirmed before it can be used to recover — otherwise an attacker who can intercept the user's email can claim any pod.
- Audit log of every issue/verify with
(identifier, purpose, outcome, ip).
Out of scope (follow-ups)
- TOTP / authenticator-app flows.
- Multi-factor combinations (Nostr signature + OTP).
- WebAuthn integration.
- Self-service recovery channel registration UI (will need an admin tool first).
Open questions
- Do recovery flows live in the pod or in a server-level admin area? If the pod is locked behind a broken ACL, pod-local OTP storage may not be reachable.
- For "claim-capability" purpose, should the OTP step be optional (a flag on the cap URI) or always required for write capabilities?
- Channel binding: should the session token be bound to the channel identifier so a stolen session can't be replayed from a different device?
Problem
JSS currently authenticates via three paths: NIP-98 (Nostr-signed), Solid-OIDC DPoP, and simple Bearer tokens (see
src/auth/middleware.js,src/auth/token.js,src/auth/nostr.js). All three assume the agent already controls a cryptographic key. We have no story for:OTP (single-use, short-lived, channel-delivered) is the standard primitive for all three.
Proposed design
A new auth path alongside the existing ones — challenge / verify / session.
Issue endpoint (
POST /.well-known/otp/request){ identifier, purpose }whereidentifieris an email or a contact URI the pod owner has previously registered as a recovery channel, andpurposeis one ofrecovery | session | claim-capability.(hash(code), purpose, identifier, expiry, attempts_remaining)in a server-side OTP table, and dispatches via the channel adapter.Verify endpoint (
POST /.well-known/otp/verify){ identifier, code, purpose }.purpose:recovery→ mint a recovery session token bound to the WebID of the identifier's owner; that session can register a new key (e.g. a new Nostr pubkey in the WebID profile'sauthenticationset).session→ mint a short-lived Bearer or DPoP token for normal auth.claim-capability→ mark a paired capability URI as activated (ties into the sibling cap-URI issue).Channel adapters (pluggable)
Storage
otp.jsonunder the pod's private area, or a server-global OTP store keyed by(podId, identifier)for recovery flows where the pod's own storage may be inaccessible (e.g. corrupted ACL).Use cases
authenticationset.Security
(identifier, purpose, outcome, ip).Out of scope (follow-ups)
Open questions