Single sign-on
Steward can offer external sign-in (Google, Sign in with Apple, SAML) next to,
or instead of, the username and password form. SSO is opt-in: a provider
exists only when you list it. With AuthProviders empty, Steward mounts no
SSO routes and the login page shows only the password form.
The protocol code lives in a separate module so the core keeps few dependencies:
go get github.com/imfiqhan/steward/contrib/ssoimport (
"github.com/imfiqhan/steward/contrib/sso/apple"
"github.com/imfiqhan/steward/contrib/sso/google"
)
g, err := google.New(ctx, google.Config{ClientID: id, ClientSecret: secret})
a, err := apple.New(ctx, apple.Config{
ServicesID: "com.example.web", TeamID: "ABCDE12345",
KeyID: "XYZ987", PrivateKey: p8PEM,
})
steward.New(steward.Config{
// ...
AuthProviders: []steward.AuthProvider{g, a}, // only these two
LinkByVerifiedEmail: true,
TrustProxy: true, // behind Caddy/nginx
})Pick exactly the providers you want. A deployment that sets up only Google gets only the Google button and the Google routes.
Routes
| Route | |
|---|---|
GET {Prefix}/auth/sso/{id} | starts sign-in and redirects to the provider |
GET and POST {Prefix}/auth/sso/{id}/callback | where the provider returns. Register this URL with the provider |
GET {Prefix}/auth/sso/{id}/metadata | service-provider metadata, for providers that publish it (SAML) |
POST {Prefix}/auth/sso/discover | maps a work email or organization to a provider. Only with AuthProviderSource |
The callback URL is PublicURL plus the path. Without PublicURL it is
derived from the request: the host, and https when the request is TLS or
(with TrustProxy) when X-Forwarded-Proto says so.
Apple and SAML return with a cross-site POST. The callback is exempt from
CSRF. Instead it checks a state value against a sealed, ten-minute cookie
(SameSite=None; Secure over HTTPS) that is set when sign-in starts and
cleared by the callback. Over plain HTTP the cookie falls back to Lax, and
POST callbacks fail. Run SSO behind HTTPS.
Which account signs in
An identity (provider plus subject) is linked to an account in the
user_identities table (with the table prefix). When someone signs in:
- A linked identity signs in its account.
- Otherwise
ResolveExternalUserdecides, if you set it. Return an account (create one for just-in-time provisioning), ornilto refuse. - Otherwise, with
LinkByVerifiedEmail, the account whose email matches a verified email is used. - Otherwise sign-in is refused with “No account is linked…”.
Whichever step resolves it, the identity is linked so the next sign-in takes
step 1. Panel.Identities(ctx, userID) lists an account’s links.
After that the usual rules apply: LoginCheck can refuse, and an account
enrolled in two-factor is sent to the challenge. SSO never skips the second
factor.
Your own sign-in methods
c.LoginAs(user) signs a user in on the current request from a handler of
your own, for example a magic link. It runs LoginCheck, starts the two-factor
challenge when due, and returns where to redirect:
dest, err := c.LoginAs(user)
var refused *steward.LoginRefusedError
if errors.As(err, &refused) { /* show refused.Error() */ }Writing a provider
A provider implements steward.AuthProvider:
type AuthProvider interface {
ID() string // URL slug, unique
Label() string // button text
Icon() string // Lucide or overlay icon name ("google" and "apple" ship)
AuthURL(ctx context.Context, req *steward.SSORequest) (string, error)
Exchange(ctx context.Context, req *steward.SSORequest, r *http.Request) (*steward.ExternalIdentity, error)
}AuthURL receives the State to send and the CallbackURL. It can keep
values for the callback (a nonce, a PKCE verifier) in req.Values; they come
back sealed in the state cookie. Exchange verifies the response and returns
the identity. Verify reports duplicate or malformed provider IDs.
Providers per tenant
AuthProviderSource supplies providers at request time, such as each
tenant’s own SAML connection:
type AuthProviderSource interface {
Provider(ctx context.Context, id string) (steward.AuthProvider, error)
Discover(ctx context.Context, hint string) (steward.AuthProvider, error)
}Setting it adds a “Work email or organization” field to the login page.
Discover maps what was typed to a provider. An identity whose TenantID is
set moves the session into that tenant after sign-in (see
multi-tenancy). contrib/sso/saml ships a source backed by a
connections table.
Password login
DisablePasswordLogin: true hides the form and answers 404 to its POST,
leaving only the providers. It does not affect /auth/token.
Tokens for other clients
A separate process, such as a mobile API that verifies a Google ID token itself, can mint tokens the panel accepts:
raw, tok, err := steward.IssueToken(ctx, db, user.ID, tenantID, "iPhone", 90*24*time.Hour)