Sign in with ReSync

People sign in to your app with their ReSync account. Your app gets a token that is the bearer the API accepts: it acts for that person, with that person's access — and never more than your app was registered for.

This is standard OpenID Connect: the authorization code flow with PKCE (S256), nothing else. Use a mainstream OIDC client library; you need two values to configure it — the issuer and your client id.

Issuer https://auth.limnal.dev
Discovery https://auth.limnal.dev/.well-known/openid-configuration
Your client id given to you when your app is registered (see Getting registered)

Your app never asks for or stores a password and has no login form of its own: the person signs in on ReSync's own pages. There is no exchange step and no second call — the access token you receive is the bearer token for the API.

Getting registered

An app is registered by an administrator of the organization that owns it: in ReSync they open the organization's Access tab, choose Connected apps → Register app, and from then on manage its redirect URIs, allowed types and machine keys on the app's own page. Send them the exact https redirect URI your app will use (scheme, host, port, path — character for character); they give you back the client id. There is no self-service registration in this version: an app owned by another organization is registered by that organization's administrator. A registration holds:

Name what people see on the consent screen
Redirect URIs where ReSync may send the browser back. Matched exactly — scheme, host, port, path and query. https only.
Allowed types the types your app may see, attest on, or change — for any person who signs in
Kind public: your app holds no secret. Confidential clients (a server-side secret at the token endpoint) are not offered yet.

An administrator can suspend your app (sign-in and every token stop working, and it can be reactivated) or revoke it (permanent). Either takes effect at sign-in at once and for tokens already issued within a minute.

Discovery

Read the endpoints from discovery; do not hard-code them.

From signin-discovery.json — status 200:

{
  "issuer": "https://auth.limnal.dev",
  "authorization_endpoint": "https://app.limnal.dev/authorize",
  "token_endpoint": "https://auth.limnal.dev/oauth/token",
  "userinfo_endpoint": "https://auth.limnal.dev/oauth/userinfo",
  "jwks_uri": "https://auth.limnal.dev/.well-known/jwks.json",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "token_endpoint_auth_methods_supported": [
    "none"
  ],
  "id_token_signing_alg_values_supported": [
    "ES256"
  ],
  "subject_types_supported": [
    "public"
  ],
  "scopes_supported": [
    "openid",
    "profile"
  ],
  "claims_supported": [
    "sub",
    "iss",
    "aud",
    "exp",
    "iat",
    "name",
    "scope"
  ]
}

The signing keys (jwks_uri) carry only public members. During a key rotation both keys are listed; pick by kid.

From signin-jwks.json — status 200:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "vQRDXP46elARbJlG7xsd-09cq5zd3VsRw5vnYx0-5W4",
      "y": "Hydvax4BwK8zwgBZVqFg3vL4euyDONt9fOQRKz648tE",
      "kid": "mjowhmaooAQ-51NL8mr_BTpCMUSsYh1kiwFOZaDugIw",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}

The flow

  1. Your app makes a PKCE pair (a random code_verifier, and code_challenge = BASE64URL(SHA-256(verifier))), a random state, and a nonce; it sends the browser to the authorization_endpoint:

    Parameter
    response_type code
    client_id your client id
    redirect_uri one of your registered redirect URIs, exactly
    scope openid (required), plus profile for the person's name. Nothing else is supported.
    state 1–512 characters; returned to you unchanged
    code_challenge 43–128 base64url characters
    code_challenge_method S256
    nonce optional, up to 255 characters; returned in the ID token
  2. The person signs in to ReSync if they are not already, and sees a consent screen: your app's name, its organization, and what it can see, attest and change. A request that is malformed, for an unknown or inactive app, or with a redirect URI your app did not register is refused on that screen — ReSync never sends the browser to a redirect URI it has not validated.

  3. Allow → the browser returns to redirect_uri?code=…&state=…. Deny → redirect_uri?error=access_denied&state=…. Check state before you use the code.

  4. Your app exchanges the code (once, within 60 seconds) at the token_endpoint, with the verifier:

    POST https://auth.limnal.dev/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code&code=…&redirect_uri=…&client_id=…&code_verifier=…
    

    A JSON body with the same fields is accepted too.

From signin-token.json — status 200:

{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1qb3dobWFvb0FRLTUxTkw4bXJfQlRwQ01VU3NZaDFraXdGT1phRHVnSXciLCJ0eXAiOiJhdCtqd3QifQ.eyJjbGllbnRfaWQiOiIwMU01VFo3RVBSUjlWTTQ3NzNNV0Y0RUZWUSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUiLCJpc3MiOiJodHRwczovL2F1dGgubGltbmFsLmRldiIsInN1YiI6IjI0OTA5IiwiYXVkIjoiMDFNNVRaN0VQUlI5Vk00NzczTVdGNEVGVlEiLCJpYXQiOjE3OTI5ODAyNzQsImV4cCI6MTc5Mjk4Mzg3NCwianRpIjoiM1BTZ2MwamQ2UGVVM0dWUTBrcWVVZyJ9.xZduW45HAD_YDgkfC3NbUaQF2SnOm8xZiYPF1vp2Z9Re6tMHSDR-0kM10re4UACQLRY_yPoBstoi_3emXSIK5w",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1qb3dobWFvb0FRLTUxTkw4bXJfQlRwQ01VU3NZaDFraXdGT1phRHVnSXciLCJ0eXAiOiJKV1QifQ.eyJhdXRoX3RpbWUiOjE3OTI5ODAyNzQsIm5vbmNlIjoibi0wUzZfV3pBMk1qIiwibmFtZSI6IlNpZ24taW4gY2FwdHVyZSBwZXJzb24iLCJpc3MiOiJodHRwczovL2F1dGgubGltbmFsLmRldiIsInN1YiI6IjI0OTA5IiwiYXVkIjoiMDFNNVRaN0VQUlI5Vk00NzczTVdGNEVGVlEiLCJpYXQiOjE3OTI5ODAyNzQsImV4cCI6MTc5Mjk4Mzg3NH0.UX2eLSQFoT-4nkHBNtwhgq-OwipnUnrvx1KINTh-_arWy2Sd-dFFomk1jV_G5Pmh8rdSu6GOK5DCky5g4Ss5MA",
  "scope": "openid profile"
}

The answer is never cached (Cache-Control: no-store). A code works once: a second exchange, a wrong verifier, a different client_id or redirect_uri all answer invalid_grant — and a failed exchange still uses the code up.

From signin-token-invalid-grant.json — status 400:

{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid, expired or already used."
}

The tokens

Both are JWTs signed with ES256; verify them against the discovery jwks_uri.

Claim Access token (typ: at+jwt) ID token (typ: JWT)
iss https://auth.limnal.dev the same
sub the person's ReSync id, as a string the same
aud your client id the same
client_id your client id —
scope the scopes granted —
jti a unique id —
iat, exp issued at; expires one hour later the same
auth_time — when the person signed in
nonce — the nonce you sent
name — the person's name, with profile

sub is stable for life — it does not change when the person's email, name or sign-in method changes. Link your records to sub, never to an email; cache only sub and a display name. No email address is shared in this version.

There are no refresh tokens. When the access token expires, run the flow again: while the person's ReSync sign-in is still current it completes without asking them anything. Treat "token expired" as one event in your app.

Calling the API

Send the access token exactly as you would a key:

Authorization: Bearer <access token>

The same endpoints, request shapes and error codes as with a key. The writes your app makes are recorded as made by the person, through your app.

Userinfo. GET https://auth.limnal.dev/oauth/userinfo with the access token answers who the person is, from ReSync data only:

From signin-userinfo.json — status 200:

{
  "sub": "24909",
  "name": "Sign-in capture person"
}

What an app token may do

An app signed in for a person may use the resource endpoints (reading, editing, members, exits, moves, attestations, status, connections), the edge and canvas endpoints, live updates, the reference reads (types, statuses, lifecycles and the rest of the model, read-only), GET /v1/orgs (the person's organizations) and GET /v1/persons/me. Every other endpoint — messages, keys, access administration, the person's own settings — answers:

From signin-client-not-permitted.json — status 403:

{
  "error": "client_not_permitted",
  "message": "An app signed in for a person cannot use this door"
}

Two permissions, both required. Every call needs the person's access (what they can reach and do in ReSync) and your app's registration (the types it may see, attest or change). Neither widens the other.

Refused because Code Remedy
the type is not among the app's types at all 403 type_not_readable the app's registration
the app may see the type but not attest or change it 403 type_not_operable the app's registration
the app may change nothing at all 403 operate_types_required the app's registration
the person's own access does not include the action 403 verb_not_granted the person's access, in ReSync
the resource is outside everything the person can reach 403 subject_outside_grant none — that is the boundary
the person's personal space is not open for changes 403 personal_modeling_off none

A 403 is final for that call: do not retry it, and do not duplicate these rules in your app — ask, and show the answer. The model itself (types, lifecycles) is readable to your app and never changeable through it. Every code is listed in errors.md.

Errors on the two OAuth endpoints

/oauth/token and /oauth/userinfo answer in the standard OAuth 2.0 / OpenID Connect shape, because client libraries expect it — the one place this API does not use its own error envelope:

{ "error": "invalid_grant", "error_description": "The authorization code is invalid, expired or already used." }
Code Endpoint Meaning
invalid_request token a parameter is missing or malformed
unsupported_grant_type token only authorization_code is supported
invalid_grant token the code is unknown, expired, used, or does not match the verifier, client or redirect URI
invalid_client token the app is not active
invalid_token userinfo the access token is invalid or expired, or its app is not active (401, with WWW-Authenticate: Bearer error="invalid_token")

On every /v1 endpoint, an app token whose app has been suspended or revoked answers 401 client_not_active.

Keys and sign-in together

Keep your app's key for work no person is doing — imports, syncs, scheduled jobs. Use the sign-in token for what a person does in your app, so it is done as them. Build one API client that takes a bearer, and one small sign-in interface — signIn(), signOut(), currentUser() returning { id, displayName } (from sub and name) — so the rest of your app does not care which it holds.

ReSync's own consent screen uses two endpoints with the person's ReSync session — never with an app token or a key (403 client_not_permitted / 403 session_required). They are listed in endpoints.md for completeness; your app does not call them.