Skip to main content

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.

Discovery
https://orangecat.ch/.well-known/openid-configuration
  • One identity. The id token's sub is 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 email is all a sign-in needs.
  • name, preferred_username and picture can 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; only sub is 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.

Register
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, or http on 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_uri and tos_uri (https only) 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.

Auth.js (Next.js) — what Solon, Heidi and Loki run
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;
    },
  },
});
Any OIDC library, or by hand — 1. send the person to
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.org
2. exchange the code
curl -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" }
3. read the profile (or decode the id token)
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:

Refresh
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:

  • openid

    Sign you in with your OrangeCat identity

  • profile

    Read your public profile (name, username, picture)

  • email

    Read your email address

  • project.read

    See your projects

  • project.write

    Create and update projects on your behalf

  • timeline.write

    Post updates to your wall on your behalf

  • service.write

    List services you offer, in your name (a client pays you for agreed work through one)

  • stakeholders.read

    Read stakeholder relationships on your projects

  • stakeholders.write

    Add stakeholder relationships on your projects

  • wallet.read

    See your wallet balances and payment methods

  • messages.read

    Read your messages

  • messages.write

    Send messages on your behalf

  • roles.write

    Post collaborator roles on your projects

  • loki.chat

    Talk to Loki and see what your fleet is doing

  • loki.act

    Let 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.

Questions, or a verified client