mirror of https://github.com/dexidp/dex.git
15 changed files with 795 additions and 30 deletions
@ -0,0 +1,70 @@
|
||||
package server |
||||
|
||||
import "fmt" |
||||
|
||||
// TokenExchangePolicy defines per-client access control for ID-JAG token exchange.
|
||||
type TokenExchangePolicy struct { |
||||
// ClientID is the client this policy applies to. Use "*" for a default policy.
|
||||
ClientID string `json:"clientID"` |
||||
AllowedAudiences []string `json:"allowedAudiences"` |
||||
AllowedScopes []string `json:"allowedScopes"` |
||||
} |
||||
|
||||
// evaluateIDJAGPolicy checks whether the client is permitted to obtain an ID-JAG
|
||||
// for the given audience and scopes. No policies configured means allow all.
|
||||
func evaluateIDJAGPolicy(policies []TokenExchangePolicy, clientID, audience string, scopes []string) error { |
||||
if len(policies) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
// Find the most-specific policy for this client: exact match first, then wildcard.
|
||||
var matched *TokenExchangePolicy |
||||
for i := range policies { |
||||
p := &policies[i] |
||||
if p.ClientID == clientID { |
||||
matched = p |
||||
break |
||||
} |
||||
if p.ClientID == "*" && matched == nil { |
||||
matched = p |
||||
} |
||||
} |
||||
|
||||
if matched == nil { |
||||
return fmt.Errorf("no policy found for client %q: access_denied", clientID) |
||||
} |
||||
|
||||
// Check audience.
|
||||
if !audienceAllowed(matched.AllowedAudiences, audience) { |
||||
return fmt.Errorf("audience %q is not allowed for client %q: access_denied", audience, clientID) |
||||
} |
||||
|
||||
// Check scopes (only if policy restricts them).
|
||||
if len(matched.AllowedScopes) > 0 { |
||||
for _, scope := range scopes { |
||||
if !scopeAllowed(matched.AllowedScopes, scope) { |
||||
return fmt.Errorf("scope %q is not allowed for client %q: access_denied", scope, clientID) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func audienceAllowed(allowedAudiences []string, audience string) bool { |
||||
for _, a := range allowedAudiences { |
||||
if a == audience { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func scopeAllowed(allowedScopes []string, scope string) bool { |
||||
for _, s := range allowedScopes { |
||||
if s == scope { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
@ -0,0 +1,102 @@
|
||||
package server |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestEvaluateIDJAGPolicy(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
policies []TokenExchangePolicy |
||||
clientID string |
||||
audience string |
||||
scopes []string |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "no policies: allow all", |
||||
policies: nil, |
||||
clientID: "any-client", |
||||
audience: "https://resource.example.com", |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "exact match allowed", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, |
||||
}, |
||||
clientID: "client-a", |
||||
audience: "https://resource.example.com", |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "audience not allowed", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://other.example.com"}}, |
||||
}, |
||||
clientID: "client-a", |
||||
audience: "https://resource.example.com", |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "client not found: denied", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, |
||||
}, |
||||
clientID: "unknown-client", |
||||
audience: "https://resource.example.com", |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "wildcard client matches", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "*", AllowedAudiences: []string{"https://resource.example.com"}}, |
||||
}, |
||||
clientID: "any-client", |
||||
audience: "https://resource.example.com", |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "exact match takes priority over wildcard", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "*", AllowedAudiences: []string{"https://other.example.com"}}, |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, |
||||
}, |
||||
clientID: "client-a", |
||||
audience: "https://resource.example.com", |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "scope denied by policy", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}, AllowedScopes: []string{"read"}}, |
||||
}, |
||||
clientID: "client-a", |
||||
audience: "https://resource.example.com", |
||||
scopes: []string{"admin"}, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "allowed scope passes", |
||||
policies: []TokenExchangePolicy{ |
||||
{ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}, AllowedScopes: []string{"read", "write"}}, |
||||
}, |
||||
clientID: "client-a", |
||||
audience: "https://resource.example.com", |
||||
scopes: []string{"read"}, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range tests { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
err := evaluateIDJAGPolicy(tc.policies, tc.clientID, tc.audience, tc.scopes) |
||||
if tc.wantErr && err == nil { |
||||
t.Error("expected error but got none") |
||||
} |
||||
if !tc.wantErr && err != nil { |
||||
t.Errorf("unexpected error: %v", err) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue