Documentation
¶
Overview ¶
Package oauth signs people in through an external provider.
Three decisions here are worth more than the rest of the package put together.
A person is matched on the provider's subject, never on the email address. Subjects are stable for the life of an account; addresses change, and providers hand a released address to somebody else. Matching on the address is how one person ends up signed in as another.
An existing person is only ever linked to a provider account when the provider says the address is verified. Without that check, anybody who can register your address at any supported provider owns your account here.
And who somebody is, is a separate question from whether they belong here. The first is global — one provider link, one identity, however many tenants — and the second is per tenant and answered no by default. A provider will authenticate anybody with a Google account, so becoming somebody here is gated by Config.AllowProvisioning, joining a tenant a sign-in named by Config.AllowJoining, and both by the tenant's own list of allowed domains.
Index ¶
Constants ¶
const ( ProviderGoogle = "Google" ProviderMicrosoft = "Microsoft" ProviderGitHub = "GitHub" )
The provider names, which are also the oauth_provider enum's labels.
const DefaultStateTTL = 10 * time.Minute
DefaultStateTTL bounds a sign-in round trip.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
Store Store
Providers []Provider
// BaseURL is the origin callbacks come back to, for example
// https://app.example.com. It must match what is registered with the
// provider, exactly.
BaseURL string
// BasePath defaults to /auth/oauth.
BasePath string
// Origin overrides BaseURL for one request, for an application served at more
// than one origin — which is exactly what host-based tenancy is:
// acme.example.com and beta.example.com are the same application, and a
// sign-in that started at one has to come back to it. The state cookie is
// host-only, so a callback that landed on the other host would arrive without
// it and be refused.
//
// Nil means BaseURL, which is right for the single-origin deployment most
// applications are.
//
// The constraint this has to live inside is the provider's, not rig's: a
// redirect URI is registered exactly, and few providers accept a wildcard. So
// every origin this returns has to be registered with every provider. A
// deployment with a tenant per subdomain and a provider that will not take a
// wildcard keeps the callback on one canonical host instead, and hands the
// finished session on to the tenant's own host itself — which is what
// OnSignIn is for.
Origin func(r *http.Request) string
// SigningKey signs the cookie that carries the state and the PKCE verifier
// across the round trip.
//
// Required, and at least 32 bytes. The alternative is a table of pending
// sign-ins to clean up; a signed cookie holds the same three values, needs
// no storage, and cannot be forged.
SigningKey []byte
// Tenant resolves which tenant is signing in, at the start of the flow, and
// may be left nil.
//
// Set it when the application already knows before the redirect — a host per
// tenant is the case that does. Leave it nil, or answer uuid.Nil, and the
// question is settled after the callback instead: the identity is resolved
// globally, and where that person goes is answered from their own
// memberships the way a password login answers it. That is the only answer
// available to a deployment with more than one tenant and no host to read
// it from — nobody can say which tenants an address belongs to before
// somebody has proved they own the address.
//
// A tenant it does name is carried through the round trip rather than
// resolved again; see the state cookie's own documentation for why it has
// to be.
Tenant func(*http.Request) (uuid.UUID, error)
// OnSignIn finishes the request once an identity is resolved.
//
// It writes the response: set a cookie, redirect with a token, render a
// page. rig does not choose, because the choice depends on whether the
// client is a browser or a native application, and only the application
// knows.
OnSignIn func(w http.ResponseWriter, r *http.Request, in SignIn) error
// OnError renders a failure, and it exists because these two routes are the
// only ones rig serves that a person reaches with their address bar.
//
// Everything else here is called by script, which can read a status and a
// body and decide what to do about it. A browser mid-navigation cannot: it
// renders whatever came back as a document, so the default below — a bare
// text/plain page on the API's own origin — is a dead end with no way back
// to the application. An application serving a front end sends a redirect
// to its own sign-in page instead, carrying copy it wrote for
// [Failure.Reason].
//
// It takes a [*Failure] rather than an error, which is where it parts
// company with
// [github.com/simonjanss/rig/auth/authhttp.Config.OnError]: there is always
// one to hand, and handing back a bare error every implementation would
// open with an errors.As is a cost with no buyer. A Failure is still an
// error, so passing it to an error writer works.
//
// It owns the response. Nothing is written after it returns, and nothing was
// written before — except the authentication-log entry, which is written
// either way and before this is called, so a hook cannot lose the record
// that a sign-in was attempted and refused.
//
// Nil falls through to [Config.Fail], and then to text/plain with the status
// the error carries.
OnError func(w http.ResponseWriter, r *http.Request, f *Failure)
// Fail is the API's own error writer, used when [Config.OnError] is nil.
//
// It is the shape every other route rig mounts takes for this —
// notifyhttp.Options.Fail, presencehttp.Options.Fail, authhttp.Config.OnError
// — which is the point of it: a generated server hands all four the same
// closure, so a refused provider sign-in is classified, answered and *logged*
// exactly as a refused login is. Without it these two routes were the only
// ones rig serves that wrote no line at any level; a failed provider sign-in
// existed in the authentication log and nowhere else.
//
// It takes an error rather than a [*Failure] because that is what an error
// writer takes. A Failure is an error and unwraps to what it carries, so
// nothing is lost passing one in.
//
// Second rather than first because [Config.OnError] answers a different
// question — what a *browser* mid-navigation should be shown — and a project
// that answered it meant it. An application serving a front end has one
// installed for it, so in practice this is what a headless deployment gets.
Fail func(w http.ResponseWriter, r *http.Request, err error)
// AllowedReturnTo are the origins a sign-in may redirect to when it
// finishes. A path on this origin is always allowed; anything else has to
// be listed, because an unchecked returnTo is an open redirect and an open
// redirect on a sign-in endpoint wears your domain in a phishing link.
AllowedReturnTo []string
// AllowProvisioning creates a person this application has never seen.
//
// Off by default. An open sign-in endpoint on a business application is a
// way for anybody with a Google account to appear inside a customer's
// tenant, which is rarely what anyone wants and never what they expect.
//
// It is the first of two doors, and it is the only one a sign-in that named
// no tenant ever reaches — there is nowhere to join, and that question is
// settled later from the person's own memberships. So for a deployment that
// leaves [Config.Tenant] nil, this is the whole switch: "may a stranger
// become somebody here at all".
//
// [Config.AllowJoining] is the second door, and follows this one unless it
// is set.
AllowProvisioning bool
// AllowIdentity decides whether a particular stranger may become an
// identity, where AllowProvisioning decides whether any may. Both are asked
// and both must agree: nil here keeps this package's behaviour exactly as it
// was, and a gate does not open a door AllowProvisioning has shut.
//
// It is the same gate account.Config holds, and one value is meant to be
// given to both — "may this person become somebody here?" is not an OAuth
// question, and a rule per door is a door without a rule the next time one
// is added. See
// [github.com/simonjanss/rig/auth/identity.Gate] for the contract.
AllowIdentity identity.Gate
// AllowJoining creates an account in the tenant a sign-in named, for
// somebody who is not in it yet. Nil follows [Config.AllowProvisioning],
// which is what one switch did when it gated both doors.
//
// It is separate because the two doors are separate decisions, and a
// deployment can want opposite answers to them. "A provider may create a
// person, but only an invitation may put them in a tenant" is the ordinary
// one — with a tenant read from a request, the join is what a crafted
// /start link would abuse, and the person on its own reaches nothing. The
// reverse is ordinary too: a host-per-tenant deployment whose people come
// from a directory elsewhere may want to admit them to the tenant the host
// named and never invent one.
//
// It only ever applies to a sign-in that named a tenant. Where none was
// named, joining is the picker's job rather than the callback's, and this
// field is not consulted.
AllowJoining *bool
Log authlog.Log
// StateTTL bounds how long a sign-in may take. Ten minutes is generous for
// a redirect and short enough that a stolen state is useless by the time
// anybody notices it.
StateTTL time.Duration
// Insecure allows the state cookie over plain HTTP. It is for local
// development and nothing else.
Insecure bool
Now func() time.Time
}
Config builds a handler.
type Failure ¶ added in v0.5.1
type Failure struct {
// Reason is which way, and it is the field an application switches on.
Reason Reason
// Provider is whose sign-in this was, lowercased as the route named it.
// Empty when the request named no provider rig knows.
Provider string
// ProviderError is the raw error value the provider put on the query
// string, for example access_denied.
//
// **For a log line, never for a page.** It is text anybody can write, and
// putting it in a redirect to your own origin is reflecting
// attacker-controlled input into your application. [Failure.Reason] already
// says what it meant, from a set rig chose.
ProviderError string
// ReturnTo is where this sign-in asked to be sent afterwards, so a failure
// can still return somebody to the page that started it rather than to a
// sign-in page's own default.
//
// It lives in the sealed state cookie, so it is known only for failures
// raised after that cookie was opened: always empty for
// [ReasonUnknownProvider], [ReasonState] and every failure at the start,
// and it is already checked against [Config.AllowedReturnTo].
ReturnTo string
// EmailAddress is who the provider said this was, lowercased. Empty until a
// profile has come back, which is most of these.
EmailAddress string
// TenantID is the tenant the sign-in was for, or uuid.Nil when it named
// none — which is an ordinary answer here rather than a missing one.
TenantID uuid.UUID
// Err is the error this is answered with when there is no hook, and the
// place a cause underneath it survives: the provider's own refusal on
// [ReasonExchange], the store's error on [ReasonInternal].
//
// Its message is written for the person who tried to sign in, **except on
// [ReasonInternal]**, where it is a seal or a store failure and rig's own
// default answers "something went wrong" instead of showing it. A hook that
// renders this message has to do the same, or it publishes on a sign-in page
// the one thing rig refuses to.
Err error
}
Failure is why a provider sign-in did not finish.
It is what Config.OnError receives, and it is an error: it wraps the rigerr.Error the failure is answered with, so github.com/simonjanss/rig/runtime/rigerr.CodeOf, an errors.As for a *rigerr.Error, and github.com/simonjanss/rig/runtime/httpx.WriteError all behave exactly as they do for the error rig would have written on its own. What it adds is the part none of them can carry: which of the fourteen ways this was.
func (*Failure) Error ¶ added in v0.5.1
Error is Failure.Err's message, so that a Failure can be returned and logged as the error it is.
A Failure rig built always carries one. The reason alone is the answer for a zero value, because a type whose String panics is a type that turns a bug in a hook into a crash in the sign-in it was handling.
func (*Failure) Unwrap ¶ added in v0.5.1
Unwrap gives up the error underneath, which is what makes github.com/simonjanss/rig/runtime/rigerr.CodeOf and an errors.As for a *rigerr.Error answer the same for a Failure as for the error it carries.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler serves the sign-in routes.
type Link ¶
type Link struct {
ID uuid.UUID
IdentityID uuid.UUID
Provider string
Subject string
EmailAddress string
}
Link is a provider account attached to a person.
It hangs off the identity and carries no tenant: one Google account is one Google account, so linking it once means "sign in with Google" works for every tenant that person belongs to.
type Profile ¶
type Profile struct {
// Subject is the provider's stable identifier. It is the only field an
// identity is matched on.
Subject string
EmailAddress string
// EmailVerified is the provider's claim that the address belongs to this
// person. An unverified address is never enough to link an existing
// account, and this is the field that decides it.
EmailVerified bool
DisplayName string
}
Profile is what a provider says about somebody.
type Provider ¶
type Provider struct {
// Name must match a label of the oauth_provider enum, since it is what
// gets stored. The built-in constructors get this right.
Name string
ClientID string
ClientSecret string
Scopes []string
Endpoint oauth2.Endpoint
// UserInfoURL is fetched with the access token to read a profile.
UserInfoURL string
// Parse turns the provider's answer into a profile. Every provider spells
// the same three facts differently, and this is where that ends.
Parse func(body []byte) (Profile, error)
// Extra runs after Parse when a provider needs a second call. GitHub does:
// its user endpoint returns a null address for anybody who kept theirs
// private.
Extra func(ctx context.Context, client *http.Client, p *Profile) error
}
Provider is one place people can sign in from.
It is a struct rather than an interface because the differences between providers are data — three URLs and a way to read a profile — and an interface would make adding one a new type instead of a literal.
type ProvisionInput ¶
ProvisionInput creates a person the application has not seen before, and links them, in one step.
type Reason ¶ added in v0.5.1
type Reason string
Reason names which way a sign-in did not finish.
It exists because github.com/simonjanss/rig/runtime/rigerr.Code cannot answer this question. Nine of the ways a provider sign-in fails are a 400, so a caller switching on the code learns only that somebody's sign-in did not work — and an application that wants to say "you cancelled" rather than "something went wrong" has nothing to switch on but the prose of rig's messages, which is a test that passes until the day somebody rewords a sentence.
The set is closed and the values are stable. An application maps them to its own copy, so a value here is a contract rather than an implementation detail: one reason per distinct thing a person or an operator could do about it, and never two reasons for one branch.
Closed does not mean it never grows. A value never changes meaning and never disappears; a branch that turns out to be two branches gains a second value, which is a switch that falls through to a default rather than one that starts lying — and one branch answering for two facts is the failure this type exists to prevent.
const ( // ReasonCancelled is the consent screen's cancel button: the provider sent // back error=access_denied and nothing else. It is the one failure that is // not a failure, and the only one worth wording as a decision rather than a // problem. ReasonCancelled Reason = "cancelled" // ReasonProviderRefused is any other error the provider reported — a policy // the account's administrator enforces, most often. // [Failure.ProviderError] says which, for a log line. ReasonProviderRefused Reason = "provider_refused" // ReasonUnknownProvider is a request for a provider this deployment does not // have. It is what a sign-in button looks like against a process started // without that provider's credentials. ReasonUnknownProvider Reason = "unknown_provider" // ReasonTenant is [Config.Tenant] refusing to say which tenant is signing // in. The application wrote that resolver, so it is the one failure here // whose cause is not rig's. ReasonTenant Reason = "tenant" // ReasonReturnTo is a returnTo that is not a valid URL or is not an allowed // destination. It is reachable only at the start, and it is a caller // mistake rather than anything the person did. ReasonReturnTo Reason = "return_to" // ReasonState is the state cookie: missing, expired, forged, belonging to // another sign-in, or naming a different provider than the route did. // // One reason for five branches, deliberately, and for the same argument the // single message behind it is one message: the commonest cause by a wide // margin is a cookie that was never sent — a callback that landed on // another host, or a sign-in finished in a different browser than it // started in — and "start again" is the whole of the advice for every one of // them. ReasonState Reason = "state" // ReasonNoCode is a callback with no authorization code on it, which is not // something a provider does when the flow is intact. ReasonNoCode Reason = "no_code" // ReasonExchange is the provider refusing to trade the code for a token. // A wrong client secret is the usual cause, and it is the reason // [Failure.Err] keeps the provider's own error underneath. ReasonExchange Reason = "exchange" // ReasonProfile is the provider not returning a profile, or returning one // rig could not read. ReasonProfile Reason = "profile" // ReasonNoAddress is a provider that authenticated somebody without sharing // an email address, so there is no account to reach. ReasonNoAddress Reason = "no_address" // ReasonUnverifiedAddress is the check this package turns on: an address the // provider has not verified is never enough to link an account that already // exists. Trying again changes nothing. ReasonUnverifiedAddress Reason = "unverified_address" // ReasonNoAccount is somebody the provider authenticated and this // application has never heard of: no identity has that address, and // provisioning is off, so there is nobody to sign in as. Also not worth // suggesting a retry for. ReasonNoAccount Reason = "no_account" // ReasonNoTenantAccess is the other half of that, and the difference is // worth two sentences of copy rather than one: this application does know // them, they are simply not in the tenant this sign-in named, and joining // is off. "We have never heard of you" and "you are not in this workspace" // are answers a person can act on differently — the second one has somebody // to ask. // // It is the same refusal a password login gives for the same fact, word for // word, because a person who signs in two ways should not be told two // different things about one situation. ReasonNoTenantAccess Reason = "no_tenant_access" // ReasonEnding is [Config.OnSignIn] returning an error — the provider said // who this is and the last step refused. A tenant they turn out not to // belong to is the usual one. ReasonEnding Reason = "ending" // ReasonInternal is a failure on this side: sealing the state, reading a // store, a provider profile with no subject in it. ReasonInternal Reason = "internal" )
The ways a provider sign-in does not finish.
type Router ¶ added in v0.6.0
type Router interface {
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
}
Router is where a route is registered. A net/http.ServeMux satisfies it, and so does the tracing router a generated server mounts through.
Declared here rather than imported for the reason the error writer below reaches rigerr rather than httpx: httpx reaches runtime/throttle and the Postgres driver behind it, and an application that mounts these two routes — or, like `auth/oauthtest`, only names this package's provider types — should not be linking that for one method name.
type SignIn ¶
type SignIn struct {
// Link is the provider account that was used, and Link.IdentityID is who it
// belongs to.
Link *Link
// TenantID and AccountID are the session to issue. The account is the
// person's row in this tenant, which is what claims are made of.
//
// **Both are uuid.Nil when the sign-in named no tenant.** That is not a
// failure: it means the provider has said who this is and where they go has
// not been decided yet, which is a question their own memberships answer.
// [Config.Tenant] is what decides whether a sign-in can arrive this way.
// The default OnSignIn handles it; a hand-written one that issues a session
// straight from these two fields has to check first.
TenantID uuid.UUID
AccountID uuid.UUID
Profile Profile
Provider string
// New reports that this sign-in created the account, so an application can
// send a welcome message or run onboarding. It is true for somebody joining
// their second tenant as well as their first: the account is new either
// way, which is what onboarding is about.
//
// It is therefore always false when no tenant was named, because there was
// no tenant to make an account in. [SignIn.NewIdentity] is the half of the
// question that still has an answer there.
New bool
// NewIdentity reports that this sign-in created the *person* — nobody had
// this provider account or this address before.
//
// The other half of New, and the half that survives a sign-in with no
// tenant: somebody signing in for the first time anywhere is the case
// onboarding is actually about, and it is answerable before there is
// anywhere for them to be.
NewIdentity bool
// ReturnTo is where the caller asked to be sent afterwards, already
// checked against the allow-list. Empty when none was asked for.
ReturnTo string
// Remember is the long-session request, from `?remember=` on the start
// route and carried across the round trip in the sealed cookie. Feed it to
// SignInIdentityInput.Remember, which the default ending does.
//
// It has no switch of its own, and does not need one: the two lengths it
// chooses between are RefreshTTL and RememberTTL, both of which the
// application configured. A deployment that does not want long provider
// sessions sets them equal and this becomes a no-op.
Remember bool
}
SignIn is what a completed sign-in hands to the application.
type Store ¶
type Store interface {
// FindLink returns the link for a provider subject, or nil. It is not
// scoped to a tenant: a provider account belongs to a person.
FindLink(ctx context.Context, provider, subject string) (*Link, error)
// FindIdentityByEmail returns the identifier of the person with that
// address, or uuid.Nil. It is what makes "sign in with Google" reach the
// person who already signed up with a password, rather than making a second
// one beside them.
FindIdentityByEmail(ctx context.Context, lowercased string) (uuid.UUID, error)
// LinkIdentity records the connection, and records the evidence for it.
//
// A link is only ever offered on an address the provider says is verified —
// [Handler.identity] refuses otherwise — so an implementation must also
// mark the identity's address verified when [Profile.EmailVerified] and it
// is not already marked. That is the same evidence ProvisionIdentity acts
// on, and writing one without the other leaves somebody who signed up with
// a password and later signed in with Google unverified on the strength of
// a column rather than of anything they did.
//
// Already verified is left alone rather than restamped: the question is
// whether the address was ever proved, and the first answer is the true one.
//
// Called on every provider sign-in that carries a verified address, not
// only the first, so it has to be idempotent: an implementation upserts on
// (provider, subject) rather than inserting. Every sign-in rather than the
// first because a link made before rig recorded the evidence has no other
// occasion to be stamped, and because the address the provider reports is
// worth keeping current.
LinkIdentity(ctx context.Context, in LinkInput) (*Link, error)
// ProvisionIdentity creates the person and links them in one step.
ProvisionIdentity(ctx context.Context, in ProvisionInput) (*Link, error)
// FindAccount returns a person's account in one tenant, or uuid.Nil when
// they do not belong to it.
FindAccount(ctx context.Context, tenantID, identityID uuid.UUID) (uuid.UUID, error)
// JoinTenant creates an account for a person in a tenant. It must honour
// the tenant's allowed email domains: a provider will authenticate anybody,
// so this is the door.
JoinTenant(ctx context.Context, in JoinInput) (uuid.UUID, error)
}
Store is the persistence a sign-in needs.
An application implements it over the generated repositories for identity, rig_identity_oauth and rig_account, all of which `rig setup-project` creates.
The two halves are deliberately separate calls. Who somebody is, is global — one address, one provider link — and whether they belong here is per tenant, so a person signing in to their second tenant goes through the first half unchanged and only the second half decides anything.