Sign in with your workspace's identity provider
Go · Go · intermediate · modification
Adds single sign-on for workspaces that connected an OIDC provider in settings. Two routes under `/w/{slug}/sso/`: `Start` hands off to the existing `oidc.Flow`, which sets the state and PKCE cookie and redirects to the provider; `Callback` finishes the flow, looks up the account by the verified email the provider returns, refuses unknown and disabled accounts with a 403 (no auto-provisioning, accounts still come from invitations) and starts a fresh session. Adds `accounts.Store.ByEmail` for the lookup. Tried the whole flow against an Okta developer org connected to a test workspace.
Go 1.22 web app for shift scheduling (stdlib `net/http` mux with method and `{slug}` patterns, `database/sql` on PostgreSQL, `log/slog`). One host serves every workspace; after sign-in, each request is scoped to the signed-in account's `workspace_id` and role. Existing and unchanged: `workspaces.Store.BySlug` returns `sql.ErrNoRows` for an unknown slug, and `Workspace.SSO` (an `*oidc.Config`) is nil when SSO is off. `oidc.Flow.Begin` stores a random state and PKCE verifier in a short-lived HttpOnly cookie and redirects to the provider; it returns an error only before writing a response. `oidc.Flow.Finish` checks that state, redeems the code, verifies the ID token against the given workspace's config (signature, issuer, audience, expiry), clears the cookie and returns the identity with a non-empty, provider-verified email in lowercase, or an error for any of those steps, including a failed or unreachable token endpoint. `session.Manager.Start` issues a new session id for the account, replacing any existing session. `accounts.Account` has `ID`, `WorkspaceID`, `Email`, `Role` and `Disabled`. The `application` struct already carries `workspaces`, `oidc`, `accounts`, `sessions` and `log`.
Requirements
- A workspace that has single sign-on turned on gets `GET /w/{slug}/sso/start` and `GET /w/{slug}/sso/callback` (the callback is the redirect URI registered with that workspace's identity provider). An unknown slug, or a workspace without SSO, answers 404 on both.
- Each workspace's admins connect their own OpenID Connect provider in settings. A workspace's provider is trusted only for sign-ins to that workspace: the callback signs the person in to the account of that workspace whose email equals the verified email the provider returned. Matching by email is deliberate (invitations are sent by email and the provider is the workspace's own directory); the provider's `sub` is not stored.
- Emails are unique per workspace, not globally: `accounts` has `UNIQUE (workspace_id, email)`, and one person can have accounts in several workspaces (for example a contractor who works for several customers), each with its own role. Emails are lowercase ASCII, stored and compared byte for byte.
- No account with that email in the workspace, or a disabled one, answers 403 with the same message: accounts are created only by invitation, never by SSO. Any error from finishing the provider flow (a bad or missing state, a code the provider will not redeem, an invalid ID token, or the provider's token endpoint failing or unreachable) is a failed sign-in: it answers 401 and is logged as a warning. Any other failure answers 500 with a generic message and is logged.
- A successful sign-in starts a fresh session for the account and redirects to `/`.
Files touched
- internal/accounts/store.go
- internal/auth/sso.go
- cmd/web/routes.go
--- internal/accounts/store.go
}
+// ByEmail returns the account that signs in with the given email, or
+// sql.ErrNoRows. Emails are stored lowercase.
+func (s *Store) ByEmail(ctx context.Context, email string) (Account, error) {
+ var a Account
+ err := s.DB.QueryRowContext(ctx,
+ `SELECT id, workspace_id, email, role, disabled
+ FROM accounts
+ WHERE email = $1`,
+ email,
+ ).Scan(&a.ID, &a.WorkspaceID, &a.Email, &a.Role, &a.Disabled)
+ return a, err
+}
+
--- internal/auth/sso.go
+package auth
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+
+ "example.com/shiftboard/internal/accounts"
+ "example.com/shiftboard/internal/oidc"
+ "example.com/shiftboard/internal/session"
+ "example.com/shiftboard/internal/workspaces"
+)