Skip to content
Single sign-on

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/sso
import (
    "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}/callbackwhere the provider returns. Register this URL with the provider
GET {Prefix}/auth/sso/{id}/metadataservice-provider metadata, for providers that publish it (SAML)
POST {Prefix}/auth/sso/discovermaps 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:

  1. A linked identity signs in its account.
  2. Otherwise ResolveExternalUser decides, if you set it. Return an account (create one for just-in-time provisioning), or nil to refuse.
  3. Otherwise, with LinkByVerifiedEmail, the account whose email matches a verified email is used.
  4. 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)