go / workos

WorkOS provides SSO and AuthKit for OAuth flows.

I use a small client over the four WorkOS endpoints I need.

Client

NewClient builds a client from a Config. Tests set the base URL and HTTP client:

const defaultAPIBase = "https://api.workos.com"

type Config struct {
	APIKey   string
	BaseURL  string
	ClientID string
	Timeout  time.Duration
}

type Client struct {
	apiKey     string
	baseURL    string
	clientID   string
	httpClient *http.Client
}

func NewClient(cfg Config) *Client {
	timeout := cfg.Timeout
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	baseURL := strings.TrimRight(cfg.BaseURL, "/")
	if baseURL == "" {
		baseURL = defaultAPIBase
	}
	return &Client{
		apiKey:     cfg.APIKey,
		baseURL:    baseURL,
		clientID:   cfg.ClientID,
		httpClient: &http.Client{Timeout: timeout},
	}
}

AuthorizationURL builds a string. No HTTP request:

func (c *Client) AuthorizationURL(organization, redirectURI, state string) string {
	q := url.Values{
		"client_id":     []string{c.clientID},
		"organization":  []string{organization},
		"redirect_uri":  []string{redirectURI},
		"response_type": []string{"code"},
		"state":         []string{state},
	}
	return c.baseURL + "/sso/authorize?" + q.Encode()
}

ProfileAndToken exchanges the callback code for a profile:

func (c *Client) ProfileAndToken(ctx context.Context, code string) (*ProfileAndTokenResponse, error) {
	form := url.Values{
		"client_id":     []string{c.clientID},
		"client_secret": []string{c.apiKey},
		"code":          []string{code},
		"grant_type":    []string{"authorization_code"},
	}
	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/sso/token", bytes.NewBufferString(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return nil, fmt.Errorf("workos profile_and_token status=%d body=%s",
			resp.StatusCode, readBodyForError(resp.Body))
	}

	raw := map[string]any{}
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return nil, err
	}
	out := &ProfileAndTokenResponse{Raw: map[string]any{}}
	if v, ok := raw["access_token"].(string); ok {
		out.AccessToken = v
	}
	if v, ok := raw["profile"].(map[string]any); ok {
		out.Profile = v
	} else {
		out.Profile = map[string]any{}
	}
	maps.Copy(out.Raw, raw)
	return out, nil
}

There are no retry loops. An auth code is single-use, so a retry of ProfileAndToken cannot succeed.

Login

The login handler builds an authorization URL and stores a CSRF state token in the session:

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	state, err := webutil.RandomHex(16)
	if err != nil {
		h.WriteError(w, 500, "oauth state generation failed")
		return
	}
	h.SessionSet(r, "oauth_state", state)

	workosAuthURL := h.WorkOS().AuthorizationURL(
		h.WorkOSOrganization,
		h.AbsoluteURL(r, "/sso"),
		state,
	)
	html, err := h.RenderPage(r, "sso/login", map[string]any{
		"workos_auth_url": workosAuthURL,
	})
	if err != nil {
		h.WriteError(w, 500, "render login failed")
		return
	}
	h.WriteHTML(w, 200, html)
}

The handler takes WorkOS as an interface, so a test injects a fake:

type WorkOSClient interface {
	AuthorizationURL(organization, redirectURI, state string) string
	ProfileAndToken(ctx context.Context, code string) (*workos.ProfileAndTokenResponse, error)
	CompleteAuthkitFlow(ctx context.Context, externalAuthID string, user map[string]any) (string, error)
	AuditEvent(ctx context.Context, organizationID string, event workos.AuditEvent) error
}

Callback

The callback verifies state, exchanges the code for a profile, and signs the user in:

func (h *Handler) SSOCallback(w http.ResponseWriter, r *http.Request) {
	expectedState := h.SessionGet(r, "oauth_state")
	h.SessionDelete(r, "oauth_state")
	if expectedState == "" || r.URL.Query().Get("state") != expectedState {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}
	code := r.URL.Query().Get("code")
	if code == "" {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}

	resp, err := h.WorkOS().ProfileAndToken(r.Context(), code)
	if err != nil {
		h.FlashError(r, "SSO error. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}
	email, _ := resp.Profile["email"].(string)

	user, err := h.lookupActiveLoginUserByEmail(r.Context(), email)
	if err != nil || user == nil {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}

	h.SetRememberCookie(w, user.RememberToken, h.CookieSecret, h.AppEnv)
	h.Redirect(w, r, h.PopReturnTo(r))
}

lookupActiveLoginUserByEmail queries WHERE active = true, so a deactivated user cannot log in.

Audit logs

A successful login writes a WorkOS audit event. The endpoint requires an Idempotency-Key header, so a retry is safe:

func (c *Client) AuditEvent(ctx context.Context, organizationID string, event AuditEvent) error {
	payload, err := json.Marshal(map[string]any{
		"organization_id": organizationID,
		"event":           event,
	})
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/audit_logs/events", bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	idempotency, err := idempotencyKey()
	if err != nil {
		return err
	}
	req.Header.Set("Idempotency-Key", idempotency)

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("workos audit_event status=%d body=%s",
			resp.StatusCode, readBodyForError(resp.Body))
	}
	return nil
}

On error, the callback logs to Sentry and does not fail the login.

AuthKit bridge

The same handlers serve an MCP AuthKit flow. A client that starts OAuth with AuthKit arrives at GET /login?external_auth_id=xxx.

For a signed-in user, the handler stores the value in the session and renders a consent page:

var externalAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`)

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	u, err := h.CurrentUser(r)
	if err != nil {
		h.WriteError(w, 500, "auth lookup failed")
		return
	}
	externalAuthID := strings.TrimSpace(r.URL.Query().Get("external_auth_id"))
	validExternalAuthID := externalAuthIDPattern.MatchString(externalAuthID)

	if u != nil && u.Active && validExternalAuthID {
		h.SessionSet(r, "pending_external_auth_id", externalAuthID)
		html, _ := h.RenderPage(r, "sso/authkit_confirm", map[string]any{
			"form_authenticity_token": h.SessionGet(r, "csrf_token"),
		})
		h.WriteHTML(w, 200, html)
		return
	}
	// ... otherwise, normal SSO flow
}

A CSRF-protected POST from the consent page completes the bind:

func (h *Handler) SSOConfirm(w http.ResponseWriter, r *http.Request) {
	u := h.RequestUser(r)
	if u == nil || !u.Active {
		h.Redirect(w, r, "/login")
		return
	}
	externalAuthID := strings.TrimSpace(h.SessionGet(r, "pending_external_auth_id"))
	h.SessionDelete(r, "pending_external_auth_id")
	if !externalAuthIDPattern.MatchString(externalAuthID) {
		h.FlashError(r, "MCP authentication session expired. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}

	redirectURI, err := h.WorkOS().CompleteAuthkitFlow(r.Context(), externalAuthID, map[string]any{
		"id":    strconv.FormatInt(u.ID, 10),
		"email": u.Email,
	})
	if err != nil || !AuthkitAllowedRedirectURI(redirectURI) {
		h.FlashError(r, "MCP authentication error. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}
	w.Header().Set("Location", redirectURI)
	w.WriteHeader(303)
}

Without the consent step, a link with ?external_auth_id=ATTACKER_ID binds the attacker's AuthKit identity to the account that clicks it.

For a guest, the callback stores external_auth_id in the session and completes AuthKit after the state check.

The handler checks the host of the returned redirect URL:

func AuthkitAllowedRedirectURI(raw string) bool {
	uri, err := url.Parse(raw)
	if err != nil {
		return false
	}
	if uri.Scheme != "https" {
		return false
	}
	host := strings.ToLower(uri.Hostname())
	return strings.HasSuffix(host, ".workos.com") || strings.HasSuffix(host, ".authkit.app")
}

Tests

Tests point the client's base URL at an httptest.NewServer:

func TestProfileAndToken(t *testing.T) {
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tu.OK(r.URL.Path == "/sso/token")
		tu.OK(r.Header.Get("Content-Type") == "application/x-www-form-urlencoded")
		w.Write([]byte(`{"access_token":"tok_123","profile":{"email":"[email protected]"}}`))
	}))
	defer s.Close()

	c := NewClient(Config{APIKey: "key", ClientID: "cid", BaseURL: s.URL})
	c.httpClient = s.Client()

	out, err := c.ProfileAndToken(context.Background(), "code-123")
	tu.OK(err == nil)
	tu.OK(out.AccessToken == "tok_123")
	tu.OK(out.Profile["email"] == "[email protected]")
}

Handler tests inject a fake WorkOSClient and run without a network.

← All articles