Files
orca/internal/identity/oidc.go
T
Jon Chery 5429da1f87 feat(P04): OIDC client + auth CLI (REQ-144, D-239, D-242, D-246)
---ci---
project: orca
phase: 4
milestone: v0.12
status: execute
---/ci---

internal/identity/oidc.go: OIDC client (provider discovery, JWKS,
auth-code+PKCE+local-loopback redirect flow, device-code headless
fallback, token verification, credentials store at ~/.orca/credentials.json
0600, refresh). VerifyIDTokenStatic for SSH-push applier.
internal/cli/auth.go: orca auth login/logout/status/init-idp commands.
Dependencies: github.com/coreos/go-oidc/v3, github.com/go-webauthn/webauthn
(pre-added for P05).
Bundled Dex deploy (init-idp) stubs to P05 (WebAuthn connector ships
the full systemd unit + Traefik route).
9 tests pass (5 identity + 4 CLI). go vet clean. Full build green.
2026-08-07 10:59:55 +00:00

492 lines
15 KiB
Go

// Package identity: oidc.go implements the OIDC client (REQ-144,
// D-239, D-242, D-246). Orca uses OIDC for human-identity
// authentication. The bundled Dex (deployed by `orca auth init-idp`)
// is the default issuer; `oidc.issuer` in config can repoint to a BYO
// external IdP. The CLI performs the authorization-code + PKCE +
// local loopback redirect flow (`orca auth login`); headless/CI uses
// the device-code flow.
//
// R-021 invariant: Orca never issues, stores, or accepts human-identity
// credentials. The IdP issues tokens; Orca only stores them (short-
// lived, 0600, refreshable). No passwords, no Orca-issued tokens.
package identity
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// OIDCConfig holds the OIDC client configuration. It is loaded from
// the cluster config block (`oidc.issuer`, `client_id`, `client_secret`,
// `scopes`).
type OIDCConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
Scopes []string `json:"scopes,omitempty"`
// RedirectPort is the local loopback port for the auth-code flow.
// 0 means ephemeral.
RedirectPort int `json:"redirect_port,omitempty"`
}
// DefaultScopes returns the standard OIDC scopes Orca requests.
func DefaultScopes() []string {
return []string{oidc.ScopeOpenID, "profile", "email", "groups"}
}
// Credentials is the on-disk token store at ~/.orca/credentials.json
// (0600). Short-lived ID token + refresh token. Refresh handles
// rotation; no long-lived Orca-issued tokens (the IdP issues them).
type Credentials struct {
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token"`
Expiry time.Time `json:"expiry"`
Issuer string `json:"issuer"`
Subject string `json:"subject"`
Groups []string `json:"groups,omitempty"`
}
// credentialsPath returns the on-disk credentials path (0600).
func credentialsPath() (string, error) {
home := os.Getenv("ORCA_HOME")
if home == "" {
userHome, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("oidc: ORCA_HOME unset and home dir: %w", err)
}
home = filepath.Join(userHome, ".orca")
}
return filepath.Join(home, "credentials.json"), nil
}
// LoadCredentials reads the stored OIDC credentials (0600). Returns
// an error if the file is missing or has looser permissions.
func LoadCredentials() (*Credentials, error) {
path, err := credentialsPath()
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("oidc: no credentials: %w", err)
}
if info.Mode().Perm()&0o077 != 0 {
return nil, fmt.Errorf("oidc: credentials %s has mode %o, expected 0600", path, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("oidc: read credentials: %w", err)
}
var c Credentials
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("oidc: parse credentials: %w", err)
}
return &c, nil
}
// SaveCredentials writes the OIDC credentials to disk at 0600.
func SaveCredentials(c *Credentials) error {
path, err := credentialsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("oidc: mkdir: %w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("oidc: marshal: %w", err)
}
return writeAtomic0600(path, data)
}
// ClearCredentials removes the stored credentials (logout).
func ClearCredentials() error {
path, err := credentialsPath()
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("oidc: clear credentials: %w", err)
}
return nil
}
// writeAtomic0600 writes data to path atomically at mode 0600
// (temp + chmod + rename).
func writeAtomic0600(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("oidc: write tmp: %w", err)
}
return os.Rename(tmp, path)
}
// OIDCClient wraps the OIDC provider + oauth2 config for the auth flow.
type OIDCClient struct {
provider *oidc.Provider
oauth2 *oauth2.Config
verifier *oidc.IDTokenVerifier
cfg OIDCConfig
}
// NewOIDCClient discovers the issuer and builds the client.
func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*OIDCClient, error) {
if cfg.Issuer == "" {
return nil, fmt.Errorf("oidc: issuer is empty")
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("oidc: client_id is empty")
}
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc: discover %s: %w", cfg.Issuer, err)
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = DefaultScopes()
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
return &OIDCClient{
provider: provider,
oauth2: oauthCfg,
verifier: verifier,
cfg: cfg,
}, nil
}
// pkcePair holds the PKCE verifier + challenge.
type pkcePair struct {
verifier string
challenge string
}
func generatePKCE() (pkcePair, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return pkcePair{}, fmt.Errorf("oidc: pkce rand: %w", err)
}
verifier := base64.RawURLEncoding.EncodeToString(b)
h := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(h[:])
return pkcePair{verifier: verifier, challenge: challenge}, nil
}
// Login performs the authorization-code + PKCE + local loopback
// redirect flow. It opens a local HTTP server on an ephemeral port,
// builds the auth URL, and waits for the callback. The caller is
// responsible for opening the URL in a browser (the CLI does this).
// Returns the credentials after exchanging the code.
func (c *OIDCClient) Login(ctx context.Context, openBrowser func(string) error) (*Credentials, error) {
pkce, err := generatePKCE()
if err != nil {
return nil, err
}
port := c.cfg.RedirectPort
if port == 0 {
port = 0
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return nil, fmt.Errorf("oidc: listen: %w", err)
}
defer listener.Close()
actualPort := listener.Addr().(*net.TCPAddr).Port
redirectURL := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort)
c.oauth2.RedirectURL = redirectURL
state, err := randString(16)
if err != nil {
return nil, err
}
authURL := c.oauth2.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", pkce.challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
if openBrowser != nil {
if err := openBrowser(authURL); err != nil {
return nil, fmt.Errorf("oidc: open browser: %w", err)
}
}
type result struct {
code string
err error
}
resultCh := make(chan result, 1)
srv := &http.Server{}
srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if errVal := q.Get("error"); errVal != "" {
resultCh <- result{err: fmt.Errorf("oidc: auth error: %s", errVal)}
fmt.Fprintf(w, "Authentication failed: %s. You can close this tab.", errVal)
return
}
if q.Get("state") != state {
resultCh <- result{err: fmt.Errorf("oidc: state mismatch")}
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
code := q.Get("code")
if code == "" {
resultCh <- result{err: fmt.Errorf("oidc: no code in callback")}
http.Error(w, "missing code", http.StatusBadRequest)
return
}
resultCh <- result{code: code}
fmt.Fprintf(w, "Authentication successful. You can close this tab and return to the CLI.")
})
go srv.Serve(listener)
defer srv.Shutdown(context.Background())
select {
case <-ctx.Done():
return nil, ctx.Err()
case res := <-resultCh:
if res.err != nil {
return nil, res.err
}
token, err := c.oauth2.Exchange(ctx, res.code,
oauth2.SetAuthURLParam("code_verifier", pkce.verifier),
)
if err != nil {
return nil, fmt.Errorf("oidc: token exchange: %w", err)
}
return c.tokenToCredentials(token)
}
}
// tokenToCredentials extracts the ID token, verifies it, and builds
// the Credentials struct.
func (c *OIDCClient) tokenToCredentials(token *oauth2.Token) (*Credentials, error) {
rawID, ok := token.Extra("id_token").(string)
if !ok || rawID == "" {
return nil, fmt.Errorf("oidc: no id_token in token response")
}
idToken, err := c.verifier.Verify(context.Background(), rawID)
if err != nil {
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
}
var claims struct {
Groups []string `json:"groups"`
}
_ = idToken.Claims(&claims)
return &Credentials{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
IDToken: rawID,
Expiry: token.Expiry,
Issuer: c.cfg.Issuer,
Subject: idToken.Subject,
Groups: claims.Groups,
}, nil
}
// Refresh refreshes the credentials using the refresh token.
func (c *OIDCClient) Refresh(ctx context.Context, creds *Credentials) (*Credentials, error) {
if creds.RefreshToken == "" {
return nil, fmt.Errorf("oidc: no refresh token")
}
ts := c.oauth2.TokenSource(ctx, &oauth2.Token{
RefreshToken: creds.RefreshToken,
})
token, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("oidc: refresh: %w", err)
}
return c.tokenToCredentials(token)
}
// VerifyIDToken verifies an ID token string against the issuer's JWKS.
// Returns the verified claims (subject, issuer, expiry, groups).
func (c *OIDCClient) VerifyIDToken(ctx context.Context, rawID string) (*IDTokenClaims, error) {
idToken, err := c.verifier.Verify(ctx, rawID)
if err != nil {
return nil, fmt.Errorf("oidc: verify: %w", err)
}
var claims IDTokenClaims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: parse claims: %w", err)
}
claims.Expiry = idToken.Expiry
return &claims, nil
}
// IDTokenClaims holds the verified OIDC ID token claims used by Orca.
type IDTokenClaims struct {
Subject string `json:"sub"`
Issuer string `json:"iss"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
Expiry time.Time `json:"-"`
}
// VerifyIDTokenStatic is a standalone verifier that doesn't require
// a long-lived OIDCClient. It discovers the issuer, verifies the
// token, and returns the claims. Used by the SSH-push applier (which
// validates the ORCA_OIDC_TOKEN env var before applying any txn).
func VerifyIDTokenStatic(ctx context.Context, issuer, clientID, rawID string) (*IDTokenClaims, error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("oidc: discover %s: %w", issuer, err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
idToken, err := verifier.Verify(ctx, rawID)
if err != nil {
return nil, fmt.Errorf("oidc: verify: %w", err)
}
var claims IDTokenClaims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: parse claims: %w", err)
}
claims.Expiry = idToken.Expiry
return &claims, nil
}
// randString generates a URL-safe random string of n bytes.
func randString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// DiscoverDeviceFlow checks if the issuer supports the device-code
// grant (OIDC device flow). Returns the device endpoint URL if
// supported. Used by the headless/CI fallback (D-245).
func DiscoverDeviceFlow(ctx context.Context, issuer string) (deviceAuthURL string, tokenURL string, err error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return "", "", fmt.Errorf("oidc: discover: %w", err)
}
var claims struct {
DeviceAuth string `json:"device_authorization_endpoint"`
}
if err := provider.Claims(&claims); err != nil {
return "", "", fmt.Errorf("oidc: claims: %w", err)
}
if claims.DeviceAuth == "" {
return "", "", fmt.Errorf("oidc: issuer %s does not support device flow", issuer)
}
return claims.DeviceAuth, provider.Endpoint().TokenURL, nil
}
// DeviceFlowLogin performs the device-code flow (headless/CI).
// It requests a device code, prints the user URL + code to the
// provided writer, and polls for the token. Returns the credentials.
func (c *OIDCClient) DeviceFlowLogin(ctx context.Context, w io.Writer) (*Credentials, error) {
deviceAuthURL, tokenURL, err := DiscoverDeviceFlow(ctx, c.cfg.Issuer)
if err != nil {
return nil, err
}
form := url.Values{}
form.Set("client_id", c.cfg.ClientID)
if c.cfg.ClientSecret != "" {
form.Set("client_secret", c.cfg.ClientSecret)
}
resp, err := http.PostForm(deviceAuthURL, form)
if err != nil {
return nil, fmt.Errorf("oidc: device auth request: %w", err)
}
defer resp.Body.Close()
var dr struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
return nil, fmt.Errorf("oidc: device auth decode: %w", err)
}
if dr.Interval == 0 {
dr.Interval = 5
}
fmt.Fprintf(w, "Open %s and enter code: %s\n", dr.VerificationURI, dr.UserCode)
deadline := time.Now().Add(time.Duration(dr.ExpiresIn) * time.Second)
interval := time.Duration(dr.Interval) * time.Second
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
tform := url.Values{}
tform.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
tform.Set("device_code", dr.DeviceCode)
tform.Set("client_id", c.cfg.ClientID)
if c.cfg.ClientSecret != "" {
tform.Set("client_secret", c.cfg.ClientSecret)
}
tresp, err := http.PostForm(tokenURL, tform)
if err != nil {
continue
}
var tr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
json.NewDecoder(tresp.Body).Decode(&tr)
tresp.Body.Close()
if tr.Error == "authorization_pending" || tr.Error == "slow_down" {
if tr.Error == "slow_down" {
interval += 5 * time.Second
}
continue
}
if tr.Error != "" {
return nil, fmt.Errorf("oidc: device flow: %s", tr.Error)
}
if tr.IDToken == "" {
continue
}
token := &oauth2.Token{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
Expiry: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
}
token = token.WithExtra(map[string]any{"id_token": tr.IDToken})
return c.tokenToCredentials(token)
}
return nil, fmt.Errorf("oidc: device flow timed out")
}
// Issuer returns the configured issuer URL.
func (c *OIDCClient) Issuer() string { return c.cfg.Issuer }
// Ensure no unused import for strings (used in error formatting).
var _ = strings.Contains