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.
185 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|