mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-07-30 06:07:38 +00:00
a70e3e02d0
Add client_secret_basic/client_secret_post client authentication, the client_credentials grant (RFC 6749 §4.4, backed by a synthetic service-account user so it reuses the existing session/introspection/RLS pipeline unchanged), RFC 9728 protected resource metadata, and OIDC discovery + JWKS + id_token/userinfo support. Remove plan_oauth.md, which was only meant as a working handoff doc. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
484 lines
16 KiB
Go
484 lines
16 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
func newTestOAuthServer(t *testing.T) (*OAuthServer, *DatabaseAuthenticator) {
|
|
t.Helper()
|
|
db := newDirectTestDB(t)
|
|
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
|
|
srv := NewOAuthServer(OAuthServerConfig{Issuer: "https://auth.example.com", PersistCodes: true}, auth)
|
|
t.Cleanup(srv.Close)
|
|
return srv, auth
|
|
}
|
|
|
|
// s256Challenge computes the PKCE S256 code_challenge for a given verifier,
|
|
// matching validatePKCESHA256 in oauth_server.go.
|
|
func s256Challenge(verifier string) string {
|
|
h := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(h[:])
|
|
}
|
|
|
|
func doJSON(t *testing.T, mux http.Handler, method, path string, body map[string]interface{}) (*httptest.ResponseRecorder, map[string]interface{}) {
|
|
t.Helper()
|
|
var reqBody *strings.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
reqBody = strings.NewReader(string(b))
|
|
} else {
|
|
reqBody = strings.NewReader("")
|
|
}
|
|
req := httptest.NewRequest(method, path, reqBody)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
var parsed map[string]interface{}
|
|
if rec.Body.Len() > 0 {
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
|
}
|
|
return rec, parsed
|
|
}
|
|
|
|
func doForm(t *testing.T, mux http.Handler, path string, form url.Values, basicUser, basicPass string) (*httptest.ResponseRecorder, map[string]interface{}) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if basicUser != "" {
|
|
req.SetBasicAuth(basicUser, basicPass)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
var parsed map[string]interface{}
|
|
if rec.Body.Len() > 0 {
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
|
}
|
|
return rec, parsed
|
|
}
|
|
|
|
func doGet(mux http.Handler, path, bearer string) (*httptest.ResponseRecorder, map[string]interface{}) {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if bearer != "" {
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
var parsed map[string]interface{}
|
|
if rec.Body.Len() > 0 {
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
|
|
}
|
|
return rec, parsed
|
|
}
|
|
|
|
func TestOAuthServer_RegisterConfidentialClient_IssuesSecretOnce(t *testing.T) {
|
|
srv, _ := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
rec, resp := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
"grant_types": []string{"client_credentials"},
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("register status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
secret, _ := resp["client_secret"].(string)
|
|
if secret == "" {
|
|
t.Fatal("expected client_secret to be present in registration response")
|
|
}
|
|
if _, ok := resp["client_secret_hash"]; ok {
|
|
t.Error("client_secret_hash must never be returned in the registration response")
|
|
}
|
|
if resp["token_endpoint_auth_method"] != "client_secret_basic" {
|
|
t.Errorf("token_endpoint_auth_method = %v, want client_secret_basic", resp["token_endpoint_auth_method"])
|
|
}
|
|
if resp["client_id"] == "" || resp["client_id"] == nil {
|
|
t.Fatal("expected non-empty client_id")
|
|
}
|
|
|
|
// Fetching the client back (e.g. via a later authorize/token call) must never leak the hash.
|
|
clientID := resp["client_id"].(string)
|
|
fetched, ok := srv.lookupOrFetchClient(context.Background(), clientID)
|
|
if !ok {
|
|
t.Fatal("expected client to be found")
|
|
}
|
|
if fetched.ClientSecretHash == "" {
|
|
t.Error("expected ClientSecretHash to be stored internally")
|
|
}
|
|
if fetched.ClientSecretHash == secret {
|
|
t.Error("stored hash must not equal the plaintext secret")
|
|
}
|
|
|
|
// Public registration (no client_credentials) should stay a public client with no secret.
|
|
recPublic, respPublic := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
})
|
|
if recPublic.Code != http.StatusCreated {
|
|
t.Fatalf("public register status = %d", recPublic.Code)
|
|
}
|
|
if _, ok := respPublic["client_secret"]; ok {
|
|
t.Error("public client registration should not receive a client_secret")
|
|
}
|
|
if respPublic["token_endpoint_auth_method"] != "none" {
|
|
t.Errorf("public client token_endpoint_auth_method = %v, want none", respPublic["token_endpoint_auth_method"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthServer_ClientCredentialsGrant(t *testing.T) {
|
|
srv, _ := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
"grant_types": []string{"client_credentials"},
|
|
"allowed_scopes": []string{"read", "write"},
|
|
})
|
|
clientID := reg["client_id"].(string)
|
|
clientSecret := reg["client_secret"].(string)
|
|
|
|
t.Run("valid credentials", func(t *testing.T) {
|
|
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"scope": {"read"},
|
|
}, clientID, clientSecret)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("token status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if resp["access_token"] == "" || resp["access_token"] == nil {
|
|
t.Error("expected non-empty access_token")
|
|
}
|
|
if _, ok := resp["refresh_token"]; ok {
|
|
t.Error("client_credentials must not issue a refresh_token (RFC 6749 §4.4.3)")
|
|
}
|
|
if resp["scope"] != "read" {
|
|
t.Errorf("scope = %v, want read", resp["scope"])
|
|
}
|
|
})
|
|
|
|
t.Run("scope not allowed for client", func(t *testing.T) {
|
|
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"scope": {"admin"},
|
|
}, clientID, clientSecret)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rec.Code)
|
|
}
|
|
if resp["error"] != "invalid_scope" {
|
|
t.Errorf("error = %v, want invalid_scope", resp["error"])
|
|
}
|
|
})
|
|
|
|
t.Run("wrong secret", func(t *testing.T) {
|
|
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
}, clientID, "wrong-secret")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
}
|
|
if resp["error"] != "invalid_client" {
|
|
t.Errorf("error = %v, want invalid_client", resp["error"])
|
|
}
|
|
})
|
|
|
|
t.Run("public client cannot use client_credentials", func(t *testing.T) {
|
|
_, pubReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
})
|
|
pubClientID := pubReg["client_id"].(string)
|
|
|
|
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {pubClientID},
|
|
}, "", "")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401 for public client attempting client_credentials", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestOAuthServer_ConfidentialClient_AuthCodeGrantRequiresSecret(t *testing.T) {
|
|
srv, auth := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
|
Username: "nadia", Password: "p", Email: "nadia@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
})
|
|
clientID := reg["client_id"].(string)
|
|
clientSecret := reg["client_secret"].(string)
|
|
|
|
verifier := "verifier-nadia-1234567890"
|
|
challenge := s256Challenge(verifier)
|
|
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
|
Code: "code-no-auth",
|
|
ClientID: clientID,
|
|
RedirectURI: "https://app.example.com/callback",
|
|
CodeChallenge: challenge,
|
|
SessionToken: regResp.Token,
|
|
Scopes: []string{"profile"},
|
|
ExpiresAt: futureTime(),
|
|
}); err != nil {
|
|
t.Fatalf("OAuthSaveCode() error = %v", err)
|
|
}
|
|
|
|
// No client credentials supplied -> must be rejected for a confidential client.
|
|
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {"code-no-auth"},
|
|
"redirect_uri": {"https://app.example.com/callback"},
|
|
"client_id": {clientID},
|
|
"code_verifier": {verifier},
|
|
}, "", "")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401 without client auth, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if resp["error"] != "invalid_client" {
|
|
t.Errorf("error = %v, want invalid_client", resp["error"])
|
|
}
|
|
|
|
// Same code, now with correct client credentials -> succeeds.
|
|
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
|
Code: "code-with-auth",
|
|
ClientID: clientID,
|
|
RedirectURI: "https://app.example.com/callback",
|
|
CodeChallenge: challenge,
|
|
SessionToken: regResp.Token,
|
|
Scopes: []string{"profile"},
|
|
ExpiresAt: futureTime(),
|
|
}); err != nil {
|
|
t.Fatalf("OAuthSaveCode() error = %v", err)
|
|
}
|
|
rec2, _ := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {"code-with-auth"},
|
|
"redirect_uri": {"https://app.example.com/callback"},
|
|
"client_id": {clientID},
|
|
"code_verifier": {verifier},
|
|
}, clientID, clientSecret)
|
|
if rec2.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 with client auth, body=%s", rec2.Code, rec2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOAuthServer_PublicClient_AuthCodeGrantUnaffected(t *testing.T) {
|
|
srv, auth := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
|
Username: "oscar", Password: "p", Email: "oscar@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
})
|
|
clientID := reg["client_id"].(string)
|
|
if _, ok := reg["client_secret"]; ok {
|
|
t.Fatal("expected no client_secret for a default (public) registration")
|
|
}
|
|
|
|
verifier := "verifier-oscar-1234567890"
|
|
challenge := s256Challenge(verifier)
|
|
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
|
Code: "public-code",
|
|
ClientID: clientID,
|
|
RedirectURI: "https://app.example.com/callback",
|
|
CodeChallenge: challenge,
|
|
SessionToken: regResp.Token,
|
|
Scopes: []string{"profile"},
|
|
ExpiresAt: futureTime(),
|
|
}); err != nil {
|
|
t.Fatalf("OAuthSaveCode() error = %v", err)
|
|
}
|
|
|
|
// No credentials needed for a public client — PKCE alone is sufficient, unchanged.
|
|
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {"public-code"},
|
|
"redirect_uri": {"https://app.example.com/callback"},
|
|
"client_id": {clientID},
|
|
"code_verifier": {verifier},
|
|
}, "", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 for public client without credentials, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOAuthServer_ProtectedResourceMetadata(t *testing.T) {
|
|
srv, _ := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
rec, resp := doGet(mux, "/.well-known/oauth-protected-resource", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if resp["resource"] != "https://auth.example.com" {
|
|
t.Errorf("resource = %v", resp["resource"])
|
|
}
|
|
servers, ok := resp["authorization_servers"].([]interface{})
|
|
if !ok || len(servers) != 1 || servers[0] != "https://auth.example.com" {
|
|
t.Errorf("authorization_servers = %v", resp["authorization_servers"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthServer_OIDCDiscoveryAndIDToken(t *testing.T) {
|
|
srv, auth := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
// Discovery document.
|
|
rec, disc := doGet(mux, "/.well-known/openid-configuration", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("discovery status = %d", rec.Code)
|
|
}
|
|
if disc["jwks_uri"] != "https://auth.example.com/oauth/jwks.json" {
|
|
t.Errorf("jwks_uri = %v", disc["jwks_uri"])
|
|
}
|
|
grantTypes, _ := disc["grant_types_supported"].([]interface{})
|
|
found := false
|
|
for _, g := range grantTypes {
|
|
if g == "client_credentials" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected client_credentials in grant_types_supported, got %v", grantTypes)
|
|
}
|
|
|
|
// JWKS.
|
|
rec, _ = doGet(mux, "/oauth/jwks.json", "")
|
|
var jwks struct {
|
|
Keys []struct {
|
|
Kid string `json:"kid"`
|
|
N string `json:"n"`
|
|
E string `json:"e"`
|
|
} `json:"keys"`
|
|
}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &jwks)
|
|
if len(jwks.Keys) != 1 {
|
|
t.Fatalf("expected 1 JWKS key, got %d", len(jwks.Keys))
|
|
}
|
|
|
|
// End-to-end authorization_code flow with scope=openid, verifying the id_token
|
|
// signature against the published JWKS key.
|
|
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
|
Username: "olivia", Password: "p", Email: "olivia@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
_, clientReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
|
|
"redirect_uris": []string{"https://app.example.com/callback"},
|
|
})
|
|
clientID := clientReg["client_id"].(string)
|
|
|
|
verifier := "test-code-verifier-1234567890"
|
|
challenge := s256Challenge(verifier)
|
|
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
|
|
Code: "oidc-code",
|
|
ClientID: clientID,
|
|
RedirectURI: "https://app.example.com/callback",
|
|
CodeChallenge: challenge,
|
|
SessionToken: regResp.Token,
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
ExpiresAt: futureTime(),
|
|
}); err != nil {
|
|
t.Fatalf("OAuthSaveCode() error = %v", err)
|
|
}
|
|
|
|
tokRec, tokenResp := doForm(t, mux, "/oauth/token", url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {"oidc-code"},
|
|
"redirect_uri": {"https://app.example.com/callback"},
|
|
"client_id": {clientID},
|
|
"code_verifier": {verifier},
|
|
}, "", "")
|
|
if tokRec.Code != http.StatusOK {
|
|
t.Fatalf("token exchange status = %d, body = %s", tokRec.Code, tokRec.Body.String())
|
|
}
|
|
idTokenStr, _ := tokenResp["id_token"].(string)
|
|
if idTokenStr == "" {
|
|
t.Fatal("expected id_token in response for scope containing openid")
|
|
}
|
|
|
|
nBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].N)
|
|
if err != nil {
|
|
t.Fatalf("decode n: %v", err)
|
|
}
|
|
eBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].E)
|
|
if err != nil {
|
|
t.Fatalf("decode e: %v", err)
|
|
}
|
|
pub := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())}
|
|
|
|
parsed, err := jwt.Parse(idTokenStr, func(token *jwt.Token) (interface{}, error) {
|
|
return pub, nil
|
|
}, jwt.WithValidMethods([]string{"RS256"}))
|
|
if err != nil || !parsed.Valid {
|
|
t.Fatalf("id_token did not verify against JWKS key: %v", err)
|
|
}
|
|
claims := parsed.Claims.(jwt.MapClaims)
|
|
if claims["iss"] != "https://auth.example.com" {
|
|
t.Errorf("iss claim = %v", claims["iss"])
|
|
}
|
|
if claims["aud"] != clientID {
|
|
t.Errorf("aud claim = %v, want %v", claims["aud"], clientID)
|
|
}
|
|
if claims["preferred_username"] != "olivia" {
|
|
t.Errorf("preferred_username claim = %v", claims["preferred_username"])
|
|
}
|
|
if claims["email"] != "olivia@example.com" {
|
|
t.Errorf("email claim = %v", claims["email"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthServer_Userinfo(t *testing.T) {
|
|
srv, auth := newTestOAuthServer(t)
|
|
mux := srv.HTTPHandler()
|
|
|
|
regResp, err := auth.Register(context.Background(), RegisterRequest{
|
|
Username: "pete", Password: "p", Email: "pete@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register() error = %v", err)
|
|
}
|
|
|
|
rec, info := doGet(mux, "/oauth/userinfo", regResp.Token)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("userinfo status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if info["preferred_username"] != "pete" {
|
|
t.Errorf("preferred_username = %v", info["preferred_username"])
|
|
}
|
|
|
|
rec2, _ := doGet(mux, "/oauth/userinfo", "not-a-real-token")
|
|
if rec2.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401 for invalid token", rec2.Code)
|
|
}
|
|
}
|