5429da1f87
---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.
492 lines
15 KiB
Go
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
|