ai

An identity provider in an afternoon

An identity provider in an afternoon

Salto, the lock vendor behind our Digital Key, offered to tie their identity server to ours. A member would get a key on their phone by signing in with their 1club account, instead of redeeming an activation code. To set it up, Salto asked for three things: our identity provider's discovery document, the client id they would use, and the redirect URL our app expects.

The problem with the first item was that we did not have an identity provider. auth.1club.ai already ran an OAuth server for our MCP connector, but it was OAuth, not OpenID Connect: opaque tokens, no id_token, no signing key, no userinfo, and only clients that register themselves. Salto's identity server needed all of the parts we were missing.

Yesterday afternoon I built those parts with Claude Code, on Opus 5.5. By the evening the work was merged, and our staging environment serves a discovery document that Salto can point at. I did not write any of the code. This post is about what that afternoon looked like, what went wrong, and what is still not done - but mostly about how uneventful it was.

Research before code

Opus started by reading Salto's documentation: the developer portal, the Connect API OpenAPI spec, a 2022 Postman collection called "Walkthrough: Salto KS login with External Idp", the discovery documents of Salto's own identity servers, and the demo apps in Salto's public SDK repository.

What came out of that was a document. Every statement in it about Salto's side cites where it came from. It pinned down the three facts the whole design rests on:

  • Salto brokers the login. The phone talks to Salto's identity server, which sends the member to ours to sign in. The token that comes back is an ordinary Salto token, so nothing about how the key is minted changes.
  • The link is by email. Salto matches its account to the member's site user through the email our provider asserts.
  • There is no just-in-time provisioning. The walkthrough says so in capitals. Our backend has to create the site user, with the right email, before the member ever signs in.

It also read Salto's own discovery document to work out what their server would accept from us - RS256 id tokens, secret-based client authentication, PKCE, the standard claims - and produced a table: what Salto's server needs, what we already had, and what to build. That table became the plan.

This is the step that is easiest to skip when writing the code by hand: skim the docs, start on the token endpoint, and find out about the email linking the first time a login fails on the acceptance environment. Here the research was cheap enough that skipping it made no sense.

What it built

A minimal OpenID Connect provider for one confidential client:

Member's phone
  → Salto identity server          /connect/authorize, PKCE
    → auth.1club.ai/oauth/authorize   member signs in to 1club
      ← code                        query or form_post
    → /v1/oidc/token                client secret + PKCE → id_token (RS256)
  ← Salto access token              device registration, key, door

Discovery, authorization, token, userinfo and JWKS endpoints; RS256 signing with key rotation; two tables for codes and access tokens; a sweep for expired rows; the member branch of the authorize page; an architecture decision record; a section in our authentication docs; environment templates; and a drafted reply to Salto with the exact values to send. About 1,500 lines for the provider itself, a third of them tests: 34 cases across the provider, the routes behind our product middleware, and the login handoff.

It did not reach for a general-purpose OIDC library. The provider is about 440 lines, written for exactly one relying party. That is the right size for what we need today, and a decision to revisit if a second client ever turns up.

The good: decisions I did not have to make

The code is fine, but what stands out is how many of its decisions were ones a review usually has to ask for.

The provider only asserts verified emails, and this is the decision that matters most. In our system an account can exist with an email nobody has verified and still sign in by phone. A club can create a member with any address. If the provider asserted that email, Salto would link the account to the real owner of the address - and hand over their keys at every other club that uses Salto. The research table did not mention it; the implementation did. It refuses unverified emails, re-checks the account when the code is exchanged, and has a test for an email that stops being verified between the two.

It also stayed separate from the MCP authorization server. Reusing the existing OAuth code would have been the obvious shortcut. It did not take it, and the reason written into the code is exact: an MCP grant belongs to an organization, needs a permission and a consent screen, and has refresh tokens; an OIDC grant belongs to a person and asserts who they are once. It shares the authorize page and nothing below it, with its own tables, so an identity grant can never show up in an organization's list of connections.

Errors are redirected only after the client and redirect URI are known. Sending an error back to an unvalidated redirect_uri is an open redirect. The error type carries a redirectable flag, and nothing is sent back until both checks have passed.

It plans for a client it does not control. Salto's identity server is a third-party library with its own defaults. So the provider tolerates unknown parameters instead of rejecting them, and puts the email and profile claims on every id token - because that library asks only for openid by default, and email is the one claim Salto cannot work without.

And a long list of smaller ones: sub is the user's UUID rather than their database id; the client secret is compared through fixed-size digests so the comparison cannot leak its length; the authorization code is claimed atomically so it really is single use; the key id is the RFC 7638 thumbprint, so rotation needs no bookkeeping; the provider is off until its signing key is configured, so the code could ship before the secret existed; and the discovery document is relayed from the API rather than rebuilt in the auth app, because - in the comment's own words - two apps computing the issuer separately is how an iss mismatch ships.

While it was in the auth app it also found seven sign-in components each checking the same redirect path on their own, six of them with an inline new URL(...) that throws on a malformed URL. They now share one helper.

Reliable, not just fast

Speed on its own says little. A model can produce 1,500 lines of plausible code in an afternoon; the question is what happens after.

When the pull request was marked ready, Codex reviewed it and raised three priority-one issues. None of them were in the identity provider. All three were in the door-access code the same branch carried from the week before - a pending booking skipped on arrival, an activation the reconcile never revisited, and an admin page that never activated the integration. Each was fixed, with a test, within the hour.

In the day since the merge, nothing in the provider has needed a fix. Staging serves the discovery document and the JWKS. I do not want to overstate that: a day is not a track record, and no real member has signed in through Salto yet. But it is the pattern I care about. The part that was researched first, written with its threat model in mind, and tested case by case was the part the second reviewer had nothing to say about.

The bad

The first problem was that it stopped at the server. The initial pass removed the activation code from the server's response and left the mobile app still trying to redeem one. The decision record even says so. The app side followed in the next pass, an hour later, and nothing broke because the Salto SDK is not linked in the app yet. On a feature already in members' hands, "the other half comes next" would not have been an acceptable intermediate state, however clearly it was written down.

The environment template assumed you knew OIDC. The first version had four terse lines: which variables exist, and a command to generate a key. It took a second pass to say which values are secret, where each one comes from, what to put in locally, and that the issuer in two different apps has to match character for character.

The research document and the code also disagree. The research table says sub is the user's database id. The implementation, correctly, uses the UUID. Instead of fixing the table, the document gained a preface saying two choices differ from the table below. It reads like two authors, which, in a sense, it is.

A smaller, ordinary problem: a migration collided on timestamp with one that had landed on main that morning, and had to be renamed.

The ugly: what no model could do

The main limit of the afternoon is that half of this integration lives on someone else's server.

Salto's callback URL is not documented. To register Salto as a client, we need the exact URL their identity server sends members back to. It is not in their documentation. Opus worked through what their server framework uses by default, identified that the callback path visible on their server is not the one to register, and - correctly - refused to guess. The value goes into an environment variable, and the reply to Salto asks for it.

Salto will also email the member. Creating a site user with an email makes Salto send an invitation to create a profile. That cannot be switched off through the API, and what the member sees of it once they have signed in through us is something only the acceptance environment can answer. It is the first of eight steps in the verification plan Opus wrote, none of which have run yet.

Production still answers 404. The provider is off there until the signing key is configured, which is by design. But it means that "done" last night meant the code, the tests, and the documents - not a member standing at a door with a working key.

Where the afternoon went

Looking back at it, very little of the afternoon was spent on code. It went on reading what Opus found and deciding whether I agreed with it. The typing, the research, the tests, the ADR and the email to the vendor were all done for me.

"Build an OpenID Connect provider" sounds like a project. Yesterday it was an afternoon, and the part with the most at stake - asserting someone's identity to a system that hands out door keys - was the part that came back with the fewest questions.

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

© 2026 Deyan Peev