Let Users Sign In to Write Reviews

The Open Reviews signer at signer.mangrove.reviews holds a signing key for every person who logs in, with an account they already have or with a passkey, and signs reviews with that key on the person’s behalf. An app that uses it manages no keys. Three calls give it a session, a signed review, and a submission to the open dataset. A person who logs in again later, from any device or any app, gets the same key, so their reviews stay under one identity.

The signer accepts logins through OAuth providers and through passkeys. The provider names are osm for OpenStreetMap, bluesky for an AT Protocol account, google, github, and passkey. A provider that is not enabled on the signer answers a login request with 400; bluesky and passkey are always enabled, since neither needs a credential at another service. An account holds several logins: a person who logged in with OpenStreetMap can add a passkey and later drop OpenStreetMap.

Register your app

Every app has a client id and a list of allowed redirect URIs. The signer sends users back only to a URI on that list, matched exactly, so include every callback URL your app uses, including local ones for development.

To get a client id, open an issue in the Mangrove repository or ask in the Open Reviews Matrix room with your app’s name and its redirect URIs.

Install the client

The signer calls are the signer module of the mangrove-reviews package. They make plain HTTP calls, so they run in a browser or in Node.

npm install mangrove-reviews

Every function takes the signer and reviewer API URLs as optional trailing arguments, which default to the public servers. Apps in other languages call the endpoints in the signer’s OpenAPI document; the signer allows requests from any origin.

Send the user to log in

Build the login URL with your client id, the callback URL the user should return to, the provider, and a random state value kept in the browser, then redirect the browser to it:

import { loginUrl } from 'mangrove-reviews/signer'

const state = crypto.randomUUID()
sessionStorage.setItem('login_state', state)
window.location.href = loginUrl(clientId, 'https://example.com/reviews/callback', 'osm', undefined, state)

For bluesky, pass the person’s handle or DID as the sixth argument when you have it; it chooses their server, and the login then checks that the account they authorize is that one. Without it the login goes to bsky.social, which hosts most accounts:

window.location.href = loginUrl(clientId, callback, 'bluesky', undefined, state, 'name.example.com')

For passkey, the signer sends the browser to a page of its own, where the person signs in with a passkey they already have or creates an account with a new one. The page runs the WebAuthn ceremony on the signer’s origin, because a browser signs only for the site the passkey was created on, so the same passkey logs in from every app that uses the signer:

window.location.href = loginUrl(clientId, callback, 'passkey', undefined, state)

The user authorizes your app at the provider, or completes the passkey ceremony, and lands on your callback URL with the session in the URL fragment:

https://example.com/reviews/callback#session_token=<token>&reviewer_id=<uuid>&did=<did>&state=<state>

The did is the person’s identity, the same on every login. The fragment stays in the browser and never reaches your server or its logs. If the user refuses, or the login fails, the fragment carries error and error_description instead, with the same state.

On the callback page, check the state, read the session, keep the token, and remove it from the address bar. The state check matters: without it a link could log the browser in to someone else’s account, so that later reviews are signed as that person.

import { parseCallback } from 'mangrove-reviews/signer'

const session = parseCallback(window.location.href)
if (session.state !== sessionStorage.getItem('login_state')) {
  showError('This login was not started here')
} else if (session.sessionToken) {
  sessionStorage.setItem('signer_token', session.sessionToken)
  sessionStorage.setItem('reviewer_id', session.reviewerId)
  sessionStorage.setItem('did', session.did)
} else {
  const params = new URLSearchParams(window.location.hash.slice(1))
  showError(params.get('error_description'))
}
sessionStorage.removeItem('login_state')
history.replaceState(null, '', window.location.pathname)

A session lasts 24 hours. After that, signing rejects with an ApiError whose status is 401, and the app sends the user through login again. Every refused call rejects the same way, with the server’s reason in body.

Sign and submit a review

Build the review payload as the Mangrove Review Standard describes. One call signs it and submits it:

import { signAndSubmitReview } from 'mangrove-reviews/signer'

const { jwt, signature, public_key } = await signAndSubmitReview(
  sessionStorage.getItem('signer_token'),
  {
    sub: 'https://example.com',
    rating: 80,
    opinion: 'Friendly staff, quick service.',
    metadata: { nickname: 'Ada' }
  }
)

The signer sets the issue time itself and signs only the fields of a review payload, so a payload that does not parse as a review is refused with 400 and the message says which field is wrong. The signature identifies the review from now on. The client has helpers for what comes after:

import { editReview, deleteReview, rateReview, reportAbuseReview } from 'mangrove-reviews/signer'

await editReview(token, signature, { rating: 90, opinion: 'Even better on a second visit.' })
await rateReview(token, otherSignature, 100, 'Helpful review')
await reportAbuseReview(token, otherSignature, 'Spam')
await deleteReview(token, signature)

Show the reviewer

The did from the callback names the reviewer in the dataset, and the public_key in the signing response is the key the signer uses for them. Pass the DID as the did filter to list everything the person has written, from any app and any key bound to the identity:

import { getReviews, getIssuer } from 'mangrove-reviews'

const mine = await getReviews({ did })
const profile = await getIssuer(did)

Reviews in the answer carry did next to kid, so a list of reviews by many people can mark the ones with an identity and link each to its profile.

Identity

Every person who logs in gets a did:plc identity whose document lists the signer’s key for them. The identity guide explains what a DID document is and how reviews bind to it; for an app using the signer, the calls below are of interest.

A recovery key lets the person take the identity away from the signer. It is a did:key of a key they hold, of a type the did:plc method allows for rotation, placed ahead of the signer’s key among the rotation keys:

import { setRecoveryKey, removeRecoveryKey } from 'mangrove-reviews/signer'

await setRecoveryKey(token, 'did:key:zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv')
await removeRecoveryKey(token)

A key of the person’s own can join the document, so reviews signed with it count under the identity. The case that matters is the local key an app used before the person logged in: list it, then let it claim the identity with claimIdentity from mangrove-reviews, and the reviews of both keys show under the DID:

import { addKey, removeKey } from 'mangrove-reviews/signer'
import { claimIdentity, didKeyFromPem, publicToPem } from 'mangrove-reviews'

const pem = await publicToPem(localKeypair.publicKey)
await addKey(token, didKeyFromPem(pem))
await claimIdentity(localKeypair, did)
// Later, to count its reviews separately again:
await removeKey(token, didKeyFromPem(pem))

A person who logged in with Bluesky can use their own identity instead of the Mangrove one, so their reviews show under their DID with their handle as verified name. Their server must allow identity operations, which is a second authorization, and it emails them a code. The app sends the browser to the page the signer returns; the browser comes back to the redirect URI with link=code_sent and the state in the fragment; the app then asks for the code:

import { linkIdentityUrl, confirmIdentity } from 'mangrove-reviews/signer'

const state = crypto.randomUUID()
sessionStorage.setItem('link_state', state)
window.location.href = await linkIdentityUrl(token, 'https://example.com/reviews/callback', state)

// On the callback page, after checking the state:
const did = await confirmIdentity(token, codeFromTheEmail)

The session must be younger than the signer’s freshness window when the link starts, or the call rejects with 403 and the app sends the person through login again. Reviews signed before the change stay under the Mangrove DID.

Publishing copies the identity’s operation log to the PLC directory shared with Bluesky, so AT Protocol software can resolve it:

import { publishIdentity } from 'mangrove-reviews/signer'

const { url } = await publishIdentity(token)

An app that offers neither still gives every person an identity; the DID arrives on every login regardless.

Passkeys of an account

A logged-in person can add a passkey to their account, whichever way they logged in, and manage the passkeys they have. Adding one runs on the signer’s page again: ask the signer for the page URL and send the browser there, and the page returns to your callback with #passkey=added&state=... in the fragment, or with #error=... when the ceremony failed.

import { passkeyAddUrl, listPasskeys, removePasskey } from 'mangrove-reviews/signer'

const state = crypto.randomUUID()
sessionStorage.setItem('passkey_state', state)
window.location.href = await passkeyAddUrl(token, 'https://example.com/reviews/callback', state)

// Later, on the account page:
const passkeys = await listPasskeys(token)
await removePasskey(token, passkeys[0].id)

Each listed passkey has an id, the name given when it was made, created_at and last_used_at. The only login of an account cannot be removed, so an account made with a passkey keeps at least one.

A change to how an account is entered needs a fresh login: passkeyAddUrl, removePasskey, setRecoveryKey, removeRecoveryKey, addKey and removeKey accept a session created within the last ten minutes and reject with status 403 otherwise. On that answer, send the person through login again and retry.

Log out

Revoke the session and forget the token:

import { revokeSession } from 'mangrove-reviews/signer'

await revokeSession(sessionStorage.getItem('signer_token'))
sessionStorage.removeItem('signer_token')

The signer is part of the Mangrove repository, under the Apache License 2.0. Its endpoints, request bodies and status codes are in its OpenAPI document.