// 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" "git.cloudinit.dev/coreci/orca/internal/security" ) // 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) } // REQ-156 / P07 T9: use the canonical security.WriteAtomic (temp // + chmod + fsync + rename) instead of the local writeAtomic0600 // (which did temp + chmod + rename with NO fsync - a crash before // rename could leave a partially-written tmp file that rename // would then promote, or the rename could land before the data // reached durable storage). return security.WriteAtomic(path, 0o600, 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 } // 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) // REQ-157 / P08 T8: set ReadHeaderTimeout so a slowloris-style // peer cannot hold the callback server open indefinitely. The // callback is short-lived (one request then Shutdown), but the // default zero ReadHeaderTimeout means an attacker who reaches the // loopback port during the brief auth window could stall the // handshake. 5s is generous for a loopback redirect. srv := &http.Server{ ReadHeaderTimeout: 5 * time.Second, } 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