Files
orca/internal/identity/oidc_test.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

185 lines
5.6 KiB
Go

package identity
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
)
// mockOIDCProvider starts a minimal OIDC provider that serves
// discovery + JWKS + token endpoint, signing self-signed ID tokens.
// It returns the issuer URL + a cleanup function.
func mockOIDCProvider(t *testing.T, clientID string) (issuer string, privateKey any, cleanup func()) {
t.Helper()
// We use a very minimal mock: discovery returns a JWKS URL +
// token URL pointing to the same test server. The token
// endpoint returns a fake ID token. For full verification
// we'd need RSA signing, but for the client logic tests we
// verify the flow wiring, not the crypto (the verifier is
// tested via integration in P26).
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": srvURL(srv),
"authorization_endpoint": srvURL(srv) + "/auth",
"token_endpoint": srvURL(srv) + "/token",
"jwks_uri": srvURL(srv) + "/jwks",
"device_authorization_endpoint": srvURL(srv) + "/device",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"none"},
})
case "/jwks":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"keys": []any{}})
case "/token":
w.Header().Set("Content-Type", "application/json")
// Return a minimal unsigned ID token (header.payload.sig
// with empty sig). The verifier in production validates
// against JWKS; for tests we only check the flow wiring.
payload := map[string]any{
"iss": srvURL(srv),
"sub": "test-user-123",
"aud": clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"groups": []string{"orca-admins"},
"email": "test@example.com",
}
payloadBytes, _ := json.Marshal(payload)
enc := base64Raw(payloadBytes)
idToken := "eyJhbGciOiJub25lIn0." + enc + "."
json.NewEncoder(w).Encode(map[string]any{
"access_token": "at-123",
"refresh_token": "rt-456",
"id_token": idToken,
"expires_in": 3600,
"token_type": "Bearer",
})
case "/device":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"device_code": "dc-123",
"user_code": "ORCA-CODE",
"verification_uri": srvURL(srv) + "/device-verify",
"interval": 1,
"expires_in": 300,
})
default:
http.NotFound(w, r)
}
}))
return srvURL(srv), nil, srv.Close
}
func srvURL(srv *httptest.Server) string {
return "http://" + srv.Listener.Addr().String()
}
func base64Raw(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// TestOIDCClientDiscovery verifies NewOIDCClient discovers the issuer.
func TestOIDCClientDiscovery(t *testing.T) {
issuer, _, cleanup := mockOIDCProvider(t, "test-client")
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := NewOIDCClient(ctx, OIDCConfig{
Issuer: issuer,
ClientID: "test-client",
})
if err != nil {
t.Fatalf("NewOIDCClient: %v", err)
}
if client.Issuer() != issuer {
t.Errorf("issuer = %q, want %q", client.Issuer(), issuer)
}
}
// TestCredentialsRoundTrip verifies Save + Load credentials round-trip
// at 0600.
func TestCredentialsRoundTrip(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
creds := &Credentials{
IDToken: "id-123",
AccessToken: "at-456",
RefreshToken: "rt-789",
Expiry: time.Now().Add(time.Hour),
Issuer: "https://idp.example",
Subject: "user-1",
Groups: []string{"admins"},
}
if err := SaveCredentials(creds); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := LoadCredentials()
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.Subject != "user-1" {
t.Errorf("subject = %q, want user-1", loaded.Subject)
}
if len(loaded.Groups) != 1 || loaded.Groups[0] != "admins" {
t.Errorf("groups = %v, want [admins]", loaded.Groups)
}
}
// TestCredentialsModeEnforced verifies LoadCredentials rejects looser
// than 0600.
func TestCredentialsModeEnforced(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
creds := &Credentials{IDToken: "x", Issuer: "x", Subject: "x"}
if err := SaveCredentials(creds); err != nil {
t.Fatalf("Save: %v", err)
}
// Loosen to 0644.
path := dir + "/credentials.json"
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := LoadCredentials()
if err == nil {
t.Error("LoadCredentials should reject 0644")
}
}
// TestClearCredentials verifies logout removes the file.
func TestClearCredentials(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
creds := &Credentials{IDToken: "x", Issuer: "x", Subject: "x"}
_ = SaveCredentials(creds)
if err := ClearCredentials(); err != nil {
t.Fatalf("Clear: %v", err)
}
if _, err := LoadCredentials(); err == nil {
t.Error("Load after clear should fail")
}
}
// TestDefaultScopes verifies the scopes include openid.
func TestDefaultScopes(t *testing.T) {
scopes := DefaultScopes()
found := false
for _, s := range scopes {
if s == "openid" {
found = true
}
}
if !found {
t.Error("DefaultScopes missing openid")
}
}