Skip to content

One-time passwords (OTP) for low-friction auth + account recovery #505

Description

@melvincarvalho

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:

  1. 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.
  2. 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.
  3. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    pluginCould be implemented as a plugin (#206); core/plugin line defined in #564

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions