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
Your app makes a PKCE pair (a random
code_verifier, andcode_challenge= BASE64URL(SHA-256(verifier))), a randomstate, and anonce; it sends the browser to theauthorization_endpoint:Parameter response_typecodeclient_idyour client id redirect_urione of your registered redirect URIs, exactly scopeopenid(required), plusprofilefor the person's name. Nothing else is supported.state1–512 characters; returned to you unchanged code_challenge43–128 base64url characters code_challenge_methodS256nonceoptional, up to 255 characters; returned in the ID token 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.
Allow → the browser returns to
redirect_uri?code=…&state=…. Deny →redirect_uri?error=access_denied&state=…. Checkstatebefore you use the code.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.
The two endpoints behind the consent screen
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.