Skip to content

Commit 51b6e85

Browse files
SamuZadCopilot
andauthored
Feature: Add OIDC Attribute-Based Authorization (#120)
* add more flags for additional flexibility * Update docs/docs/configuration.md Co-authored-by: Copilot <[email protected]> * go fmt + npx prettier --------- Co-authored-by: Copilot <[email protected]>
1 parent ec9e857 commit 51b6e85

9 files changed

Lines changed: 477 additions & 24 deletions

File tree

docs/docs/configuration.md

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,18 @@ Complete reference for all MCP Auth Proxy configuration options.
5555

5656
#### Generic OIDC
5757

58-
| Option | Environment Variable | Default | Description |
59-
| --------------------------- | ------------------------- | ---------------------- | ------------------------------------------------------------ |
60-
| `--oidc-configuration-url` | `OIDC_CONFIGURATION_URL` | - | OIDC configuration URL |
61-
| `--oidc-client-id` | `OIDC_CLIENT_ID` | - | OIDC client ID |
62-
| `--oidc-client-secret` | `OIDC_CLIENT_SECRET` | - | OIDC client secret |
63-
| `--oidc-allowed-users` | `OIDC_ALLOWED_USERS` | - | Comma-separated list of allowed OIDC users (exact match) |
64-
| `--oidc-allowed-users-glob` | `OIDC_ALLOWED_USERS_GLOB` | - | Comma-separated list of glob patterns for allowed OIDC users |
65-
| `--oidc-provider-name` | `OIDC_PROVIDER_NAME` | `OIDC` | Display name for OIDC provider |
66-
| `--oidc-scopes` | `OIDC_SCOPES` | `openid,profile,email` | Comma-separated list of OIDC scopes |
67-
| `--oidc-user-id-field` | `OIDC_USER_ID_FIELD` | `/email` | JSON pointer to user ID field in userinfo endpoint response |
58+
| Option | Environment Variable | Default | Description |
59+
| -------------------------------- | ------------------------------ | ---------------------- | --------------------------------------------------------------------------------- |
60+
| `--oidc-configuration-url` | `OIDC_CONFIGURATION_URL` | - | OIDC configuration URL |
61+
| `--oidc-client-id` | `OIDC_CLIENT_ID` | - | OIDC client ID |
62+
| `--oidc-client-secret` | `OIDC_CLIENT_SECRET` | - | OIDC client secret |
63+
| `--oidc-allowed-users` | `OIDC_ALLOWED_USERS` | - | Comma-separated list of allowed OIDC users (exact match) |
64+
| `--oidc-allowed-users-glob` | `OIDC_ALLOWED_USERS_GLOB` | - | Comma-separated list of glob patterns for allowed OIDC users |
65+
| `--oidc-allowed-attributes` | `OIDC_ALLOWED_ATTRIBUTES` | - | Comma-separated list of allowed attribute key=value pairs (e.g., `/groups=admin`) |
66+
| `--oidc-allowed-attributes-glob` | `OIDC_ALLOWED_ATTRIBUTES_GLOB` | - | Comma-separated list of attribute key=pattern pairs for glob matching |
67+
| `--oidc-provider-name` | `OIDC_PROVIDER_NAME` | `OIDC` | Display name for OIDC provider |
68+
| `--oidc-scopes` | `OIDC_SCOPES` | `openid,profile,email` | Comma-separated list of OIDC scopes |
69+
| `--oidc-user-id-field` | `OIDC_USER_ID_FIELD` | `/email` | JSON pointer to user ID field in userinfo endpoint response |
6870

6971
##### OIDC User Matching
7072

@@ -87,6 +89,46 @@ You can use both exact matching and glob patterns for OIDC user authorization:
8789
--oidc-allowed-users-glob "*@example.com"
8890
```
8991

92+
##### OIDC Attribute-Based Authorization
93+
94+
You can also authorize users based on attributes from the userinfo endpoint (e.g., group memberships, roles, departments). This is useful when you need to restrict access based on IdP-provided claims beyond just the user ID.
95+
96+
- **Exact attribute matching** (`--oidc-allowed-attributes`): Attribute values must match exactly
97+
- **Glob attribute patterns** (`--oidc-allowed-attributes-glob`): Attribute values are matched against glob patterns
98+
99+
Attribute keys use [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) syntax to reference fields in the userinfo response. Both string and array attribute values are supported.
100+
101+
**Examples:**
102+
103+
```bash
104+
# Allow users in the "engineering" department
105+
--oidc-allowed-attributes "/department=engineering"
106+
107+
# Allow users in the "admin" group (works with array values)
108+
--oidc-allowed-attributes "/groups=admin"
109+
110+
# Allow users whose group matches a pattern
111+
--oidc-allowed-attributes-glob "/groups=*-admins"
112+
113+
# Nested attribute (e.g., {"org": {"team": "platform"}})
114+
--oidc-allowed-attributes "/org/team=platform"
115+
116+
# Multiple allowed values for the same attribute
117+
--oidc-allowed-attributes "/groups=admin,/groups=developers"
118+
119+
# Combined with user matching
120+
--oidc-allowed-users "[email protected]" \
121+
--oidc-allowed-attributes "/groups=data-science" \
122+
--oidc-allowed-attributes-glob "/groups=*-admins"
123+
```
124+
125+
:::tip Okta Configuration
126+
For Okta, you typically need to:
127+
128+
1. Add the `groups` scope: `--oidc-scopes "openid,profile,email,groups"`
129+
2. Configure a groups claim in Okta Admin (Security → API → Authorization Servers → Claims)
130+
:::
131+
90132
### Cryptographic Key Options
91133

92134
- **`AUTH_HMAC_SECRET`** — Base64-encoded 32-byte secret for HMAC/cookie signing. Default: auto-generated.

docs/docs/examples.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ services:
4545
- OIDC_CONFIGURATION_URL=https://your-oidc-provider/.well-known/openid_configuration
4646
- OIDC_CLIENT_ID=your-oidc-client-id
4747
- OIDC_CLIENT_SECRET=your-oidc-client-secret
48+
- OIDC_SCOPES=openid,profile,email,groups
4849
4950
- OIDC_ALLOWED_USERS_GLOB=*@example.com
51+
- OIDC_ALLOWED_ATTRIBUTES=/groups=admin,/department=engineering
52+
- OIDC_ALLOWED_ATTRIBUTES_GLOB=/groups=*-admins
5053
- TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
5154
volumes:
5255
- ./data:/data

docs/docs/oauth-setup.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,36 @@ Configure OAuth providers to enable secure authentication for your MCP server.
125125
-- your-mcp-command
126126
```
127127

128+
#### Attribute-based authorization:
129+
130+
Authorize users based on attributes like group memberships or roles from the userinfo endpoint:
131+
132+
```bash
133+
./mcp-auth-proxy \
134+
--external-url https://{your-domain} \
135+
--tls-accept-tos \
136+
--oidc-configuration-url "https://your-provider.com/.well-known/openid-configuration" \
137+
--oidc-client-id "your-oidc-client-id" \
138+
--oidc-client-secret "your-oidc-client-secret" \
139+
--oidc-scopes "openid,profile,email,groups" \
140+
--oidc-allowed-attributes "/groups=engineering" \
141+
-- your-mcp-command
142+
```
143+
144+
#### Attribute glob patterns:
145+
146+
```bash
147+
./mcp-auth-proxy \
148+
--external-url https://{your-domain} \
149+
--tls-accept-tos \
150+
--oidc-configuration-url "https://your-provider.com/.well-known/openid-configuration" \
151+
--oidc-client-id "your-oidc-client-id" \
152+
--oidc-client-secret "your-oidc-client-secret" \
153+
--oidc-scopes "openid,profile,email,groups" \
154+
--oidc-allowed-attributes-glob "/groups=*-admins" \
155+
-- your-mcp-command
156+
```
157+
128158
### Provider-Specific Examples
129159

130160
#### Okta
@@ -133,6 +163,27 @@ Configure OAuth providers to enable secure authentication for your MCP server.
133163
--oidc-configuration-url "https://your-domain.okta.com/.well-known/openid-configuration"
134164
```
135165

166+
For group-based authorization with Okta:
167+
168+
1. Add the `groups` scope to your request:
169+
170+
```bash
171+
--oidc-scopes "openid,profile,email,groups"
172+
```
173+
174+
2. Configure a groups claim in Okta Admin:
175+
176+
- Go to Security → API → Authorization Servers
177+
- Select your authorization server → Claims tab
178+
- Add a claim named "groups" with value type "Groups" and filter as needed
179+
180+
3. Use attribute-based authorization:
181+
```bash
182+
--oidc-allowed-attributes "/groups=data-science"
183+
# or with glob patterns:
184+
--oidc-allowed-attributes-glob "/groups=*-admins"
185+
```
186+
136187
#### Auth0
137188

138189
```bash
@@ -181,8 +232,11 @@ export GITHUB_ALLOWED_ORGS="org1,org2:team1"
181232
export OIDC_CONFIGURATION_URL="https://provider.com/.well-known/openid-configuration"
182233
export OIDC_CLIENT_ID="your-oidc-client-id"
183234
export OIDC_CLIENT_SECRET="your-oidc-client-secret"
235+
export OIDC_SCOPES="openid,profile,email,groups"
184236
export OIDC_ALLOWED_USERS="[email protected],[email protected]"
185237
export OIDC_ALLOWED_USERS_GLOB="*@example.com"
238+
export OIDC_ALLOWED_ATTRIBUTES="/groups=admin,/department=engineering"
239+
export OIDC_ALLOWED_ATTRIBUTES_GLOB="/groups=*-admins"
186240

187241
./mcp-auth-proxy --external-url https://{your-domain} --tls-accept-tos -- your-mcp-command
188242
```

main.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,33 @@ func splitWithEscapes(s, delimiter string) []string {
6464
return result
6565
}
6666

67+
// parseAttributeMap parses a comma-separated string of key=value pairs into a map
68+
// where each key can have multiple values. Format: /key1=value1,/key1=value2,/key2=value3
69+
// Keys are JSON pointers to attributes in the userinfo response.
70+
func parseAttributeMap(s string) map[string][]string {
71+
result := make(map[string][]string)
72+
if s == "" {
73+
return result
74+
}
75+
parts := splitWithEscapes(s, ",")
76+
for _, part := range parts {
77+
part = strings.TrimSpace(part)
78+
if part == "" {
79+
continue
80+
}
81+
eqIdx := strings.Index(part, "=")
82+
if eqIdx == -1 {
83+
continue
84+
}
85+
key := strings.TrimSpace(part[:eqIdx])
86+
value := strings.TrimSpace(part[eqIdx+1:])
87+
if key != "" && value != "" {
88+
result[key] = append(result[key], value)
89+
}
90+
}
91+
return result
92+
}
93+
6794
type proxyRunnerFunc func(
6895
listen string,
6996
tlsListen string,
@@ -93,6 +120,8 @@ type proxyRunnerFunc func(
93120
oidcProviderName string,
94121
oidcAllowedUsers []string,
95122
oidcAllowedUsersGlob []string,
123+
oidcAllowedAttributes map[string][]string,
124+
oidcAllowedAttributesGlob map[string][]string,
96125
noProviderAutoSelect bool,
97126
password string,
98127
passwordHash string,
@@ -138,6 +167,8 @@ func newRootCommand(run proxyRunnerFunc) *cobra.Command {
138167
var oidcProviderName string
139168
var oidcAllowedUsers string
140169
var oidcAllowedUsersGlob string
170+
var oidcAllowedAttributes string
171+
var oidcAllowedAttributesGlob string
141172
var noProviderAutoSelect bool
142173
var password string
143174
var passwordHash string
@@ -194,6 +225,9 @@ func newRootCommand(run proxyRunnerFunc) *cobra.Command {
194225
oidcAllowedUsersGlobList = splitWithEscapes(oidcAllowedUsersGlob, ",")
195226
}
196227

228+
oidcAllowedAttributesMap := parseAttributeMap(oidcAllowedAttributes)
229+
oidcAllowedAttributesGlobMap := parseAttributeMap(oidcAllowedAttributesGlob)
230+
197231
var oidcScopesList []string
198232
if oidcScopes != "" {
199233
oidcScopesList = strings.Split(oidcScopes, ",")
@@ -250,6 +284,8 @@ func newRootCommand(run proxyRunnerFunc) *cobra.Command {
250284
oidcProviderName,
251285
oidcAllowedUsersList,
252286
oidcAllowedUsersGlobList,
287+
oidcAllowedAttributesMap,
288+
oidcAllowedAttributesGlobMap,
253289
noProviderAutoSelect,
254290
password,
255291
passwordHash,
@@ -298,6 +334,8 @@ func newRootCommand(run proxyRunnerFunc) *cobra.Command {
298334
rootCmd.Flags().StringVar(&oidcProviderName, "oidc-provider-name", getEnvWithDefault("OIDC_PROVIDER_NAME", "OIDC"), "Display name for OIDC provider")
299335
rootCmd.Flags().StringVar(&oidcAllowedUsers, "oidc-allowed-users", getEnvWithDefault("OIDC_ALLOWED_USERS", ""), "Comma-separated list of allowed OIDC users")
300336
rootCmd.Flags().StringVar(&oidcAllowedUsersGlob, "oidc-allowed-users-glob", getEnvWithDefault("OIDC_ALLOWED_USERS_GLOB", ""), "Comma-separated list of glob patterns for allowed OIDC users")
337+
rootCmd.Flags().StringVar(&oidcAllowedAttributes, "oidc-allowed-attributes", getEnvWithDefault("OIDC_ALLOWED_ATTRIBUTES", ""), "Comma-separated list of allowed attribute key=value pairs (e.g., /groups=admin,/roles=editor). Keys are JSON pointers.")
338+
rootCmd.Flags().StringVar(&oidcAllowedAttributesGlob, "oidc-allowed-attributes-glob", getEnvWithDefault("OIDC_ALLOWED_ATTRIBUTES_GLOB", ""), "Comma-separated list of attribute key=pattern pairs for glob matching (e.g., /groups=*-admins,/email=*@example.com). Keys are JSON pointers.")
301339

302340
// Password authentication
303341
rootCmd.Flags().BoolVar(&noProviderAutoSelect, "no-provider-auto-select", getEnvBoolWithDefault("NO_PROVIDER_AUTO_SELECT", false), "Disable auto-redirect when only one OAuth/OIDC provider is configured and no password is set")

main_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,89 @@ func TestSplitWithEscapes(t *testing.T) {
8585
}
8686
}
8787

88+
func TestParseAttributeMap(t *testing.T) {
89+
testCases := []struct {
90+
name string
91+
input string
92+
expected map[string][]string
93+
}{
94+
{
95+
name: "empty string",
96+
input: "",
97+
expected: map[string][]string{},
98+
},
99+
{
100+
name: "single key-value pair",
101+
input: "/groups=admin",
102+
expected: map[string][]string{
103+
"/groups": {"admin"},
104+
},
105+
},
106+
{
107+
name: "multiple values for same key",
108+
input: "/groups=admin,/groups=users",
109+
expected: map[string][]string{
110+
"/groups": {"admin", "users"},
111+
},
112+
},
113+
{
114+
name: "multiple keys",
115+
input: "/groups=admin,/department=engineering",
116+
expected: map[string][]string{
117+
"/groups": {"admin"},
118+
"/department": {"engineering"},
119+
},
120+
},
121+
{
122+
name: "nested key with JSON pointer",
123+
input: "/org/team=platform",
124+
expected: map[string][]string{
125+
"/org/team": {"platform"},
126+
},
127+
},
128+
{
129+
name: "glob pattern value",
130+
input: "/groups=*-admins,/email=*@example.com",
131+
expected: map[string][]string{
132+
"/groups": {"*-admins"},
133+
"/email": {"*@example.com"},
134+
},
135+
},
136+
{
137+
name: "whitespace trimming",
138+
input: " /groups = admin , /role = editor ",
139+
expected: map[string][]string{
140+
"/groups": {"admin"},
141+
"/role": {"editor"},
142+
},
143+
},
144+
{
145+
name: "invalid format - no equals sign",
146+
input: "invalid",
147+
expected: map[string][]string{},
148+
},
149+
{
150+
name: "invalid format - empty key",
151+
input: "=value",
152+
expected: map[string][]string{},
153+
},
154+
{
155+
name: "invalid format - empty value",
156+
input: "/key=",
157+
expected: map[string][]string{},
158+
},
159+
}
160+
161+
for _, tc := range testCases {
162+
t.Run(tc.name, func(t *testing.T) {
163+
result := parseAttributeMap(tc.input)
164+
if !reflect.DeepEqual(result, tc.expected) {
165+
t.Errorf("Expected %v, got %v", tc.expected, result)
166+
}
167+
})
168+
}
169+
}
170+
88171
func TestGetEnvWithDefault(t *testing.T) {
89172
testCases := []struct {
90173
name string
@@ -251,6 +334,8 @@ func TestNewRootCommand_HTTPStreamingOnlyFlag(t *testing.T) {
251334
oidcProviderName string,
252335
oidcAllowedUsers []string,
253336
oidcAllowedUsersGlob []string,
337+
oidcAllowedAttributes map[string][]string,
338+
oidcAllowedAttributesGlob map[string][]string,
254339
noProviderAutoSelect bool,
255340
password string,
256341
passwordHash string,
@@ -312,6 +397,8 @@ func TestNewRootCommand_HTTPStreamingOnlyFromEnv(t *testing.T) {
312397
oidcProviderName string,
313398
oidcAllowedUsers []string,
314399
oidcAllowedUsersGlob []string,
400+
oidcAllowedAttributes map[string][]string,
401+
oidcAllowedAttributesGlob map[string][]string,
315402
noProviderAutoSelect bool,
316403
password string,
317404
passwordHash string,

0 commit comments

Comments
 (0)