๐ค oidc
amadan.net/rastrillo/rastrillo/oidc
Sign in with a third-party identity provider โ Microsoft Entra ID or
Google Workspace today โ as a first factor beside password
and auth's own keymail/magic-link flow, sharing
sessions the same way they do. Ported from an
app that ran it in production, with a design document recording why each
decision is the way it is: docs/superpowers/specs/2026-10-26-oidc-signin-design.md
in this repository.
It is split into a verification core that holds no database handle and
knows nothing about any app (Engine, Verify, Admitted, the provider
list), and an HTTP layer that wires it into one app's *sessions.Sessions
(Handlers, Config, New, Begin, Return).
Why this is written rather than imported
The obvious dependency is a general JOSE/OIDC client library, carrying a JWE implementation, key wrapping, and a dozen signature algorithms this package will never present. What is actually needed is one signature algorithm over one token shape, in the standard library. The narrowness is the security property, not a limitation:
- RS256 and nothing else. Every other algorithm โ
noneand every HMAC family included โ is refused BY NAME. The algorithm-confusion attack works by handing a verifier a token whose header asks it to check an HMAC with a key it holds as public; a verifier that implements one algorithm cannot be talked into another. - No refresh token is asked for, kept, or stored. This flow establishes who is at the door once.
- No userinfo call. The identity comes out of the signed ID token, never out of a bearer-authenticated HTTP response.
- https with no exception, not even for loopback. Tests stand a real TLS server up rather than carrying a "local" exemption.
Providers: a closed list, not an issuer box
type Kind string
const (
Microsoft Kind = "microsoft"
Google Kind = "google"
)
type Provider struct {
Kind Kind
Label string
Directory string
DirectoryHint string
NeedsHostedDomain bool
RedirectHint string
}
func Providers() []Provider
func ProviderFor(kind Kind) (Provider, bool)
func (p Provider) NeedsDirectory() bool
func (p Provider) Issuer(directory string) (string, error)
func (p Provider) Refusal(directory string) string
An issuer field would let an app point this package at anything answering
an OpenID discovery document โ a wider trust decision than "we use
Microsoft" or "we use Google", and not one this package makes on an app's
behalf. Providers() is the whole list; a third provider is a reviewed
row plus tests beside it, never a value an app supplies at runtime.
Provider.Issuer builds the issuer identifier an app stores on its
Connection from the one value a firm actually types (a directory/tenant
ID for Microsoft, nothing for Google) โ call Provider.Refusal before
saving a connection, so an app is told what is wrong with a value at the
point of typing. Provider.NeedsDirectory says whether a provider asks
for one at all.
Provider.NeedsHostedDomain marks a provider whose issuer is shared by
every customer it has (Google: https://accounts.google.com for a
Workspace tenant and a personal Gmail account alike). For such a
provider, Connection.AllowedDomains is required and Admitted checks
the token's own hd claim against it โ the only thing in the signature
that says which organisation the token came from.
The verification core
type Connection struct {
Issuer, ClientID, ClientSecret, RedirectURI string
AllowedDomains []string
}
type Metadata struct {
Issuer, AuthorizationEndpoint, TokenEndpoint, JWKSURI string
}
type Engine struct {
HTTP *http.Client
Now func() time.Time
}
func (e *Engine) Discover(ctx context.Context, issuer string) (Metadata, error)
func (e *Engine) Verify(ctx context.Context, m Metadata, conn Connection, raw, nonce string) (Identity, error)
func (e *Engine) Exchange(ctx context.Context, m Metadata, conn Connection, code string, s Start) (Identity, error)
func AuthCodeURL(m Metadata, conn Connection, s Start, prompt string) string
Build exactly one Engine per process. It caches every discovery
document and signing-key set it has fetched, keyed by issuer and by JWKS
URI respectively โ never by Kind โ so a hub app with many tenants under
one provider kind cannot have one tenant's signing keys verify another
tenant's tokens. A fresh Engine per request means a cold cache every
time; Handlers (below) holds one for exactly this reason.
Begin and Return (below) both refuse a Connection whose Issuer
does not have the exact shape Provider.Issuer builds for its own
Kind โ ErrIssuerKindMismatch. This closes a gap found in code
review: Provider.NeedsHostedDomain is decided by Kind, so a
connection labelled microsoft whose Issuer was actually some other,
shared-issuer provider's value would never be asked for
AllowedDomains and never have hd checked, admitting whoever that
OTHER issuer signs for. Checked on every use, not only when a
Connection is saved.
Discover refuses two shapes of discovery document: one whose own
issuer disagrees with the issuer it was fetched from, and one whose
issuer contains { or } โ Microsoft's multi-tenant endpoints
(/common, /organizations, /consumers) answer with a placeholder
there, and accepting that would admit an Entra account from any
organisation in the world.
Verify is exported separately from Exchange so a test can drive it
with a token it minted itself; nothing in this package's own HTTP wiring
calls it directly. It checks the signature first, against a key fetched
from the issuer's own published JWKS, and reads nothing inside the token
before that succeeds. After the signature: iss against the discovery
document, aud against Connection.ClientID, azp when aud carries
more than one value, nonce against the one minted for this ceremony,
and exp/nbf/iat with a two-minute clock-skew allowance.
var ErrToken error // any refusal of an ID token
var ErrNotConfigured error
var ErrMultiTenant error // Discover's placeholder-issuer refusal
var ErrInsecure error // a URL in this flow was not https
var ErrStateMismatch error // Start.CheckState's refusal
type Error struct {
Stage, Message, Body string
Status int
}
func (e *Error) Error() string
Error carries the stage a failure happened at and the provider's own
words, bounded โ useful to somebody administering a connection, never
shown to whoever is trying to sign in.
Identity and the admission question
type Identity struct {
Subject, Issuer, Address, Name, HostedDomain, Tenant string
AddressVerified Tristate
Methods []string
AuthTime time.Time
}
type Tristate int
const (
Unstated Tristate = iota
Asserted
Denied
)
func (t Tristate) String() string
func Admitted(p Provider, conn Connection, id Identity) error
var ErrNotAdmitted error
Every field on Identity was inside the verified signature; nothing was
read from a query string or an unauthenticated response. AddressVerified
is a tri-state rather than a bool because a provider's SILENCE about the
claim is not a denial: Entra never emits email_verified at all, and
folding that silence into false would refuse every Entra sign-in.
Admitted is the connection-level admission rule every provider needs,
run before an app's own membership hook ever sees the identity: no
address at all, or AddressVerified == Denied, is refused; Unstated is
let through. For a NeedsHostedDomain provider, Identity.HostedDomain
must be present and match one of Connection.AllowedDomains. Subject
(the token's sub) is recorded and does not by itself resolve an app's
account โ see Config.Admit, below, for why mapping it to a subject is
the app's decision rather than this package's default.
The ceremony
type Start struct {
State, Nonce, Verifier string
}
func NewStart() (Start, error)
func (s Start) Challenge() string
func (s Start) Valid() bool
func (s Start) CheckState(returned string) error
Start is the three unguessable values that bind one sign-in attempt
together: state is compared on return and stops somebody else's
completed ceremony being replayed into this browser; nonce travels
inside the ID token's signature and stops a token minted for another
attempt being presented to this one; the PKCE verifier (hashed into
Challenge() on the way out) stops an intercepted authorization code
being exchanged by anybody who does not hold it.
The ceremony itself โ Start plus which connection and provider it is
for, and its own expiry โ is sealed into a cookie under the app's
instance key, never stored in a table: an anonymous caller pressing a
sign-in button repeatedly writes nothing to disk, and the cookie IS the
browser binding, since a ceremony begun in one browser cannot be finished
in another. Its expiry is checked from INSIDE the sealed value against
the clock, never trusted from the cookie's own Max-Age alone, which is
a property of the browser rather than of this instance.
Wiring one app
type ConnectionKey string
type Config struct {
Sessions *sessions.Sessions
InstanceKey string
Connections func(ctx context.Context, kind Kind, key ConnectionKey) (Connection, bool, error)
ConnectionKey func(r *http.Request) ConnectionKey
Admit func(ctx context.Context, conn Connection, id Identity) (subject string, err error)
SecondFactor func(w http.ResponseWriter, r *http.Request, sess sessions.Session) (done bool, err error)
Refused func(w http.ResponseWriter, r *http.Request, id Identity, reason error)
SignedInPath string
Logger *slog.Logger
Now func() time.Time
HTTP *http.Client
}
func New(cfg Config) (*Handlers, error)
type Handlers struct{}
func (h *Handlers) Begin(w http.ResponseWriter, r *http.Request)
func (h *Handlers) Return(w http.ResponseWriter, r *http.Request)
New validates the configuration at boot: Sessions, an InstanceKey of
at least 32 bytes, Connections and Admit are all required, so an app
that wires this up and forgets the membership question fails at boot
rather than admitting whoever a directory hands back.
Mount Begin and Return yourself, under whatever path carries a
{provider} URL parameter (chi.URLParam(r, "provider")):
r.Get("/signin/oidc/{provider}", h.Begin)
r.Get("/signin/oidc/{provider}/return", h.Return)
Connections resolves a provider Kind and a ConnectionKey (opaque to
this package โ "" for a single-tenant app) to an app's registration.
ConnectionKey is read ONCE, at Begin, and sealed into the ceremony;
Return never calls the hook again, because a hub's tenants are routed by
path on one shared origin and the provider's own callback carries nothing
that would let Return re-derive which tenant a ceremony belongs to the
same way. This is also the whole answer to running on a hub product (a
signed-mode *sessions.Sessions, sessions):
Begin/Return contain no branch on how Sessions was built, because
Sessions.SignIn already knows.
Admit maps a verified, already-domain-checked identity to the app's
OWN subject โ never the address itself. This matters for two reasons
stated on the field's own doc comment: a signed-mode Sessions refuses
an address as a subject outright, and secondfactor.Gate looks up who
has a passkey or TOTP enrolled by this exact subject, so an app with
password or keymail sign-in already wired must return the same subject
those use for the same person. SecondFactor always runs AFTER Admit
succeeds, never before: calling it first would let an identity Admit
would have refused reach a pending half-session with no further
admission check.
Refused answers an identity that verified but was not admitted โ
Admitted's own refusals, Admit's refusal, or a step-up
(?reauth=1, honouring sessions.Sessions.RequireFresh) the provider did
not actually satisfy freshly. Nil gets a bare 403 naming nothing beyond
"not admitted here"; only someone who has just proved they hold the
address reaches this at all, so naming the reason is no oracle for an
anonymous caller โ every OTHER failure (a malformed return, an expired
ceremony, a token that fails verification) answers one generic page
regardless of cause, logged for an operator and never disclosed.
var ErrStepUpNotFresh error
ErrStepUpNotFresh is what Refused is called with when Begin asked
the provider for a fresh sign-in (prompt=login&max_age=0, sent when the
request carries ?reauth=1) and the resulting token's auth_time is not
within the last minute โ some providers answer an already-interactive
session with a stale auth_time regardless of max_age, and this is
refused rather than minted as a session that would only loop at the next
freshness check.
A sign-in without ?reauth=1 may be answered from the provider's own session, which can be days old. When the token carries no auth_time, as Google's usually don't, the session records an authentication time of 1970, so it never counts as fresh: RequireFresh sends the person back through ?reauth=1, and that sign-in must show an auth_time from the last minute.
Under a base path
Under a basepath, and on the app's direct host, Begin and Return answer 404 and log an error before they set a cookie or call the provider, because a tenant there signs people in through its hub.
What this does not do
No account provisioning โ an address must already be one Admit accepts.
No group or role mapping. No single log-out: ending a session here does
nothing at the provider, and turning someone off at the provider only
stops their NEXT sign-in, not a session already open โ set a short
SessionTTL on Sessions if faster enforcement matters. No amr/acr
policy: a provider's claim about its own second factor is recorded on
Identity.Methods and decides nothing, for the same reason a firm's own
"our tenant enforces MFA" checkbox is not a fact this package can stand
behind. No second factor in Signed (hub) mode today. secondfactor's own Method suffix does not fit Signed mode's key-id alphabet, a gap between those two core packages rather than anything specific to this one, so New refuses a SecondFactor with a Signed-mode Sessions.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.