Add bounded resource audiences and enforce browser scope grants
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b
This commit is contained in:
parent
b8dda4115a
commit
403904b901
22 changed files with 449 additions and 35 deletions
134
src/internal/server/oidc/audience_test.go
Normal file
134
src/internal/server/oidc/audience_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package oidc_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/server/oidc"
|
||||
"keycape/internal/server/telemetry"
|
||||
)
|
||||
|
||||
func verifyWithJWKS(t *testing.T, h *oidc.TokenHandler, token string) {
|
||||
t.Helper()
|
||||
keys := oidc.NewKeySet()
|
||||
keys.AddKey("key-1", &h.SigningKey.PublicKey)
|
||||
w := httptest.NewRecorder()
|
||||
oidc.NewJWKSHandler(keys).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/jwks", nil))
|
||||
var response struct {
|
||||
Keys []oidc.JWK `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Keys) != 1 {
|
||||
t.Fatal("missing signing key")
|
||||
}
|
||||
key := response.Keys[0]
|
||||
n, err := base64.RawURLEncoding.DecodeString(key.N)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := base64.RawURLEncoding.DecodeString(key.E)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatal("invalid JWT")
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
pub := rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
|
||||
if err := rsa.VerifyPKCS1v15(&pub, crypto.SHA256, digest[:], sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAudienceIsStaticAndDefaultsToClientID(t *testing.T) {
|
||||
for _, audience := range []string{"", "approval-engine"} {
|
||||
t.Run(audience, func(t *testing.T) {
|
||||
h := serviceTokenHandler(t)
|
||||
h.ClientConfig["rapp-qonto"].Audience = audience
|
||||
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "audience": {"attacker"}, "resource": {"attacker"}})
|
||||
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
||||
want := audience
|
||||
if want == "" {
|
||||
want = "rapp-qonto"
|
||||
}
|
||||
if parseJWTPayload(t, token)["aud"] != want {
|
||||
t.Fatal("wrong audience")
|
||||
}
|
||||
verifyWithJWKS(t, h, token)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanResourceAudiencePreservesIDTokenAudience(t *testing.T) {
|
||||
sessions := oidc.NewSessionStore()
|
||||
h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}})
|
||||
h.ClientConfig["test-client"].Audience = "approval-engine"
|
||||
h.ClientConfig["test-client"].AllowedScopes = []string{"openid", "approval:approve"}
|
||||
verifier := "test-verifier"
|
||||
code := seededSession(sessions, verifier)
|
||||
sess, _ := sessions.Get(code)
|
||||
sess.Scopes = []string{"openid", "approval:approve"}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
response := decodeTokenResponse(t, w.Body.String())
|
||||
access := response["access_token"].(string)
|
||||
id := response["id_token"].(string)
|
||||
claims := parseJWTPayload(t, access)
|
||||
if claims["aud"] != "approval-engine" || claims["scope"] != "openid approval:approve" || claims["principal_type"] != "human" {
|
||||
t.Fatalf("wrong access claims: %v", claims)
|
||||
}
|
||||
if parseJWTPayload(t, id)["aud"] != "test-client" {
|
||||
t.Fatal("ID token audience changed")
|
||||
}
|
||||
verifyWithJWKS(t, h, access)
|
||||
verifyWithJWKS(t, h, id)
|
||||
}
|
||||
|
||||
func TestHumanExcessScopeRejectedBeforeAuthentication(t *testing.T) {
|
||||
h := newAuthorizeHandler(nil, nil, telemetry.NoopEmitter{})
|
||||
params := validAuthorizeParams()
|
||||
params.Set("scope", "openid approval:consume")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRejectsScopeRemovedAfterAuthorization(t *testing.T) {
|
||||
sessions := oidc.NewSessionStore()
|
||||
h, _ := newTokenHandler(t, sessions, &mockUserRepo{})
|
||||
code := seededSession(sessions, "verifier")
|
||||
h.ClientConfig["test-client"].AllowedScopes = []string{"openid"}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"verifier"}}))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +190,13 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
return
|
||||
}
|
||||
|
||||
for _, requestedScope := range strings.Fields(scope) {
|
||||
if !containsString(client.AllowedScopes, requestedScope) {
|
||||
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Validate code_challenge is present.
|
||||
if codeChallenge == "" {
|
||||
profileerrors.InvalidProfileUsage(
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ func testClient() map[string]*domain.Client {
|
|||
ClientID: "test-client",
|
||||
DisplayName: "Test Client",
|
||||
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||
AllowedScopes: []string{"openid", "profile", "email"},
|
||||
AllowedScopes: []string{"openid", "profile", "email", "groups"},
|
||||
ClientType: "public",
|
||||
},
|
||||
"netkingdom-bootstrap-console": {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,14 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Recheck grants in case the client registration changed after authorization.
|
||||
for _, scope := range sess.Scopes {
|
||||
if !containsString(h.ClientConfig[clientID].AllowedScopes, scope) {
|
||||
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify PKCE code_verifier.
|
||||
if !verifyPKCE(codeVerifier, sess.PKCEChallenge) {
|
||||
profileerrors.InvalidProfileUsage(
|
||||
|
|
@ -169,12 +177,24 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Access tokens target the statically registered resource server. ID tokens
|
||||
// remain bound to the OIDC relying party.
|
||||
if audience := h.ClientConfig[clientID].Audience; audience != "" {
|
||||
claims["aud"] = audience
|
||||
}
|
||||
claims["scope"] = strings.Join(sess.Scopes, " ")
|
||||
accessToken, err := buildJWT(claims, kid, h.SigningKey)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 8. Delete used PKCE session (prevent replay).
|
||||
h.Sessions.Delete(code)
|
||||
|
||||
// 9. Build response.
|
||||
resp := tokenResponse{
|
||||
AccessToken: jwtToken,
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(h.TokenLifetime.Seconds()),
|
||||
IDToken: jwtToken,
|
||||
|
|
@ -239,7 +259,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
|||
claims := map[string]interface{}{
|
||||
"iss": h.Issuer,
|
||||
"sub": client.ServiceSubject,
|
||||
"aud": clientID,
|
||||
"aud": accessAudience(client),
|
||||
"exp": now.Add(tokenLifetime).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"tenant": client.Tenant,
|
||||
|
|
@ -393,3 +413,11 @@ func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (s
|
|||
|
||||
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
|
||||
}
|
||||
|
||||
// accessAudience is configured by the issuer, never selected by request input.
|
||||
func accessAudience(client *domain.Client) string {
|
||||
if client.Audience != "" {
|
||||
return client.Audience
|
||||
}
|
||||
return client.ClientID
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue