Sign in with OrangeCat
One account for every door. Standard OpenID Connect, self-service registration, and the same identity across OrangeCat, Solon, Loki, Heidi and your site.
What you get
OrangeCat is an OpenID Connect provider. Any library that speaks OIDC works unchanged: point it at the issuer and discovery fills in the rest.
https://orangecat.ch/.well-known/openid-configuration- One identity. The id token's
subis the person's OrangeCat actor id, the same value on every site that uses this button. Key your users by it. Never by email: two accounts may share one, and a person may change theirs. - Passwords are optional. People sign in with an emailed code, Google, GitHub or a password. Forgotten passwords, recovery and account deletion are OrangeCat's problem, not yours.
- People stay in control. Every app that signed in as them is listed under Settings → Integrations → Connected apps, with a Disconnect button. Honour it (see “Sessions” below).
- Claims. sub, iss, aud, exp, iat, name, preferred_username, picture, email. Scope
openid profile emailis all a sign-in needs. name,preferred_usernameandpicturecan be missing. People choose what each app is shown and can change it later. A missing claim means “not given to you”, so show nothing and do not ask again; onlysubis always there, and it is the one key to store. A change applies the next time they sign in to you, so read the claims from every sign-in and clear what is no longer sent.
Register your app
Self-service, right now. One request registers a public client (RFC 7591). No account, no review, no waiting.
curl -s https://orangecat.ch/oauth/register \
-H 'content-type: application/json' \
-d '{
"client_name": "My app",
"redirect_uris": ["https://my-app.example/auth/callback/orangecat"],
"policy_uri": "https://my-app.example/privacy",
"tos_uri": "https://my-app.example/terms",
"token_endpoint_auth_method": "none"
}'
# → { "client_id": "dcr_…", "redirect_uris": [...], ... }- Redirect URIs must be
https, orhttpon localhost for a native app. At most 5. Exact match, no wildcards. - No client secret is issued; PKCE (S256) binds each code to the app that asked for it.
- The consent screen always shows for a self-registered app, says it is unverified, and names the host it will send the person back to. Your app's name is trimmed to 80 characters; logos and homepage URLs are not shown.
- Send
policy_uriandtos_uri(httpsonly) and the consent screen links them; without them it tells the person your app has published neither.
Verified client. Once your site is live, ask for a verified client: a confidential client with a secret (client_secret_post), your name shown without the warning, and consent remembered so returning people skip the screen.
Add the button
Label it “Sign in with OrangeCat”, or “Create an account” with prompt=create where the person is new. Add login_hint when you already know the email; OrangeCat opens with it filled in.
import NextAuth from "next-auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
providers: [
{
id: "orangecat",
name: "OrangeCat",
type: "oidc",
issuer: "https://orangecat.ch", // discovery does the rest
clientId: process.env.ORANGECAT_OAUTH_CLIENT_ID!,
clientSecret: process.env.ORANGECAT_OAUTH_CLIENT_SECRET, // omit for a self-registered (public) client
client: { token_endpoint_auth_method: "client_secret_post" }, // "none" for a public client
checks: ["pkce", "state"],
authorization: { params: { scope: "openid profile email" } },
},
],
callbacks: {
jwt({ token, profile, account }) {
// id_token.sub is the OrangeCat actor id: the ONLY key that identifies
// a person across sites. Never link accounts by email.
if (profile?.sub) token.actorId = profile.sub;
if (account?.provider === "orangecat") {
token.ocRefreshToken = account.refresh_token;
token.ocExpiresAt = account.expires_at;
}
return token;
},
session({ session, token }) {
session.actorId = token.actorId as string | undefined;
return session;
},
},
});https://orangecat.ch/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fmy-app.example%2Fauth%2Fcallback%2Forangecat
&scope=openid%20profile%20email
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S256
# optional: &prompt=create (open on "Create an account")
# &login_hint=someone%40example.orgcurl -s https://orangecat.ch/oauth/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://my-app.example/auth/callback/orangecat \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=THE_VERIFIER \
# confidential clients add: -d client_secret=YOUR_SECRET
# → { "access_token", "id_token", "refresh_token", "expires_in", "token_type": "Bearer" }curl -s https://orangecat.ch/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
# → { "sub": "<actor id>", "name", "preferred_username", "picture", "email" }Verify id tokens against https://orangecat.ch/oauth/jwks.json (RS256). Always send state and PKCE; send nonce and check it in the id token.
Sessions, and taking access back
Access tokens live 1 hour; refresh tokens 30 days from their last use. A self-registered (public) app gets a new refresh token on every use; a verified app with a secret keeps the same one, so a refresh whose response you could not save does no harm. Either way, store the refresh_token the response carries and the expiry with your session, and refresh when the access token runs out:
curl -s https://orangecat.ch/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=THE_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID
# 200 → a NEW refresh_token (the old one is spent) and a new access_token
# 400 {"error":"invalid_grant"} → the person disconnected your app. End their session.invalid_grant means the person disconnected your app (or signed out everywhere, or deleted their account). End their session on your site and send them back to the button. Any other failure is transient: keep the session and try again shortly. A session that never refreshes outlives the person's decision; that is the one mistake to avoid.
Scopes
A sign-in needs only the standard three. The rest let an app act for the person and are shown on the consent screen in these words:
openidSign you in with your OrangeCat identity
profileRead your public profile (name, username, picture)
emailRead your email address
project.readSee your projects
project.writeCreate and update projects on your behalf
timeline.writePost updates to your wall on your behalf
service.writeList services you offer, in your name (a client pays you for agreed work through one)
stakeholders.readRead stakeholder relationships on your projects
stakeholders.writeAdd stakeholder relationships on your projects
wallet.readSee your wallet balances and payment methods
messages.readRead your messages
messages.writeSend messages on your behalf
roles.writePost collaborator roles on your projects
loki.chatTalk to Loki and see what your fleet is doing
loki.actLet Loki act for you: dispatch work to your projects, book appointments, and approve or reject queued actions — approving runs the action
Try the whole round trip from a terminal before you write a line: node scripts/oauth/sign-in-roundtrip.mjs in the OrangeCat repository registers a client, opens the sign-in, exchanges the code, reads the profile and refreshes.