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

Files touched

--- 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"
+)

Review this PR

Go practice