Merge e54d18d08d into 110d51d1d7
This commit is contained in:
commit
4643e9aba4
|
|
@ -7,7 +7,7 @@ title: Keycloak OIDC
|
||||||
|
|
||||||
| Flag | Toml Field | Type | Description | Default |
|
| Flag | Toml Field | Type | Description | Default |
|
||||||
| ---------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | ------- |
|
| ---------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||||
| `--allowed-role` | `allowed_roles` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | |
|
| `--allowed-role` | `allowed_roles` | string \| list | Restrict logins to users with this role (may be given multiple times). Works with the keycloak-oidc and ms-entra-id provider. | |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ The provider is OIDC-compliant, so all the OIDC parameters are honored. Addition
|
||||||
| --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| `--entra-id-allowed-tenant` | `entra_id_allowed_tenants` | string \| list | List of allowed tenants. In case of multi-tenant apps, incoming tokens are issued by different issuers and OIDC issuer verification needs to be disabled. When not specified, all tenants are allowed. Redundant for single-tenant apps (regular ID token validation matches the issuer). | |
|
| `--entra-id-allowed-tenant` | `entra_id_allowed_tenants` | string \| list | List of allowed tenants. In case of multi-tenant apps, incoming tokens are issued by different issuers and OIDC issuer verification needs to be disabled. When not specified, all tenants are allowed. Redundant for single-tenant apps (regular ID token validation matches the issuer). | |
|
||||||
| `--entra-id-federated-token-auth` | `entra_id_federated_token_auth` | boolean | Enable oAuth2 client authentication with federated token projected by Entra Workload Identity plugin, instead of client secret. | false |
|
| `--entra-id-federated-token-auth` | `entra_id_federated_token_auth` | boolean | Enable oAuth2 client authentication with federated token projected by Entra Workload Identity plugin, instead of client secret. | false |
|
||||||
|
| `--allowed-role` | `allowed_roles` | string \| list | Restrict logins to users with this app role (may be given multiple times). Works with the keycloak-oidc and ms-entra-id provider. | |
|
||||||
|
|
||||||
## Configure App registration
|
## Configure App registration
|
||||||
To begin, create an App registration, set a redirect URI, and generate a secret. All account types are supported, including single-tenant, multi-tenant, multi-tenant with Microsoft accounts, and Microsoft accounts only.
|
To begin, create an App registration, set a redirect URI, and generate a secret. All account types are supported, including single-tenant, multi-tenant, multi-tenant with Microsoft accounts, and Microsoft accounts only.
|
||||||
|
|
|
||||||
|
|
@ -609,7 +609,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
|
||||||
|
|
||||||
flagSet.String("user-id-claim", OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
|
flagSet.String("user-id-claim", OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
|
||||||
flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
|
flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
|
||||||
flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)")
|
flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc, ms-entra-id) restrict logins to members of these roles (may be given multiple times)")
|
||||||
flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token")
|
flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token")
|
||||||
|
|
||||||
return flagSet
|
return flagSet
|
||||||
|
|
@ -775,6 +775,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
|
||||||
provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
|
provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
|
||||||
AllowedTenants: l.EntraIDAllowedTenants,
|
AllowedTenants: l.EntraIDAllowedTenants,
|
||||||
FederatedTokenAuth: l.EntraIDFederatedTokenAuth,
|
FederatedTokenAuth: l.EntraIDFederatedTokenAuth,
|
||||||
|
Roles: l.AllowedRoles,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,9 @@ type MicrosoftEntraIDOptions struct {
|
||||||
// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
|
// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
|
||||||
// by Entra Workload Identity plugin, instead of client secret.
|
// by Entra Workload Identity plugin, instead of client secret.
|
||||||
FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"`
|
FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"`
|
||||||
|
|
||||||
|
// Role enables to restrict login to users with app role
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ADFSOptions struct {
|
type ADFSOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
||||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
||||||
|
|
@ -110,14 +108,9 @@ type accessClaims struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) {
|
func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) {
|
||||||
parts := strings.Split(s.AccessToken, ".")
|
payload, err := extractAccessTokenPayload(s)
|
||||||
if len(parts) < 2 {
|
|
||||||
return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims accessClaims
|
var claims accessClaims
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,16 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros
|
||||||
name: microsoftEntraIDProviderName,
|
name: microsoftEntraIDProviderName,
|
||||||
})
|
})
|
||||||
|
|
||||||
return &MicrosoftEntraIDProvider{
|
provider := &MicrosoftEntraIDProvider{
|
||||||
OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig),
|
OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig),
|
||||||
|
|
||||||
multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
|
multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
|
||||||
federatedTokenAuth: opts.MicrosoftEntraIDConfig.FederatedTokenAuth,
|
federatedTokenAuth: opts.MicrosoftEntraIDConfig.FederatedTokenAuth,
|
||||||
microsoftGraphURL: microsoftGraphURL,
|
microsoftGraphURL: microsoftGraphURL,
|
||||||
}
|
}
|
||||||
|
provider.addAllowedRoles(opts.MicrosoftEntraIDConfig.Roles)
|
||||||
|
|
||||||
|
return provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichSession checks for group overage after calling generic EnrichSession
|
// EnrichSession checks for group overage after calling generic EnrichSession
|
||||||
|
|
@ -74,7 +77,7 @@ func (p *MicrosoftEntraIDProvider) EnrichSession(ctx context.Context, session *s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return p.extractRoles(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateSession checks for allowed tenants (e.g. for multi-tenant apps) and passes through to generic ValidateSession
|
// ValidateSession checks for allowed tenants (e.g. for multi-tenant apps) and passes through to generic ValidateSession
|
||||||
|
|
@ -154,7 +157,7 @@ func (p *MicrosoftEntraIDProvider) RefreshSession(ctx context.Context, s *sessio
|
||||||
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
|
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, p.extractRoles(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// redeemRefreshTokenWithFederatedToken uses a RefreshToken and federated credentials with the RedeemURL to refresh the
|
// redeemRefreshTokenWithFederatedToken uses a RefreshToken and federated credentials with the RedeemURL to refresh the
|
||||||
|
|
@ -320,3 +323,45 @@ func (p *MicrosoftEntraIDProvider) fetchToken(ctx context.Context, params url.Va
|
||||||
|
|
||||||
return token.WithExtra(rawResponse), nil
|
return token.WithExtra(rawResponse), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addAllowedRoles sets app roles that are authorized.
|
||||||
|
// Assumes `SetAllowedGroups` is already called on groups and appends to that
|
||||||
|
// with `role:` prefixed roles.
|
||||||
|
func (p *MicrosoftEntraIDProvider) addAllowedRoles(roles []string) {
|
||||||
|
if p.AllowedGroups == nil {
|
||||||
|
p.AllowedGroups = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
p.AllowedGroups[formatAppRole(role)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rolesClaim struct {
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MicrosoftEntraIDProvider) extractRoles(s *sessions.SessionState) error {
|
||||||
|
// Get 'roles' claim from access token payload
|
||||||
|
payload, err := extractAccessTokenPayload(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var claim rolesClaim
|
||||||
|
if err := json.Unmarshal(payload, &claim); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles []string
|
||||||
|
roles = append(roles, claim.Roles...)
|
||||||
|
|
||||||
|
// Add to groups list with `role:` prefix to distinguish from groups
|
||||||
|
for _, role := range roles {
|
||||||
|
s.Groups = append(s.Groups, formatAppRole(role))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAppRole(role string) string {
|
||||||
|
return fmt.Sprintf("role:%s", role)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
||||||
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -183,3 +184,67 @@ type mockedVerifier struct {
|
||||||
func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
|
func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type registeredClaimsWithRoles struct {
|
||||||
|
// the `roles` (Roles) claim. See https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps#usage-scenario-of-app-roles
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAzureEntraOIDCProviderValidateSessionAllowedRoles(t *testing.T) {
|
||||||
|
// Create multi-tenant Azure Entra provider with allowed roles
|
||||||
|
provider := NewMicrosoftEntraIDProvider(
|
||||||
|
&ProviderData{
|
||||||
|
Verifier: &mockedVerifier{},
|
||||||
|
},
|
||||||
|
options.Provider{
|
||||||
|
OIDCConfig: options.OIDCOptions{
|
||||||
|
IssuerURL: "https://login.microsoftonline.com/common/v2.0",
|
||||||
|
InsecureSkipIssuerVerification: true,
|
||||||
|
InsecureSkipNonce: true,
|
||||||
|
},
|
||||||
|
MicrosoftEntraIDConfig: options.MicrosoftEntraIDOptions{
|
||||||
|
Roles: []string{"Owner", "Contributor"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check for access token don't have allowed roles
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
|
||||||
|
claimsWithEmptyRoles := ®isteredClaimsWithRoles{}
|
||||||
|
claimsWithEmptyRoles.Issuer = "https://login.microsoftonline.com/85d7d600-7804-4d92-8d43-9c33c21c130c/v2.0"
|
||||||
|
claimsWithEmptyRoles.Roles = nil
|
||||||
|
|
||||||
|
idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claimsWithEmptyRoles)
|
||||||
|
unauthorizedRoleJWT, err := idToken.SignedString(key)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
session := &sessions.SessionState{
|
||||||
|
AccessToken: "unauthorized_token",
|
||||||
|
Groups: nil,
|
||||||
|
}
|
||||||
|
session.IDToken = unauthorizedRoleJWT
|
||||||
|
|
||||||
|
authorized, _ := provider.Authorize(context.Background(), session)
|
||||||
|
assert.False(t, authorized)
|
||||||
|
|
||||||
|
// Check for access token has one of allowed roles
|
||||||
|
claimsWithAuthorizedRole := ®isteredClaimsWithRoles{}
|
||||||
|
claimsWithAuthorizedRole.Issuer = "https://login.microsoftonline.com/85d7d600-7804-4d92-8d43-9c33c21c130c/v2.0"
|
||||||
|
claimsWithAuthorizedRole.Roles = []string{"Owner"}
|
||||||
|
|
||||||
|
idToken = jwt.NewWithClaims(jwt.SigningMethodRS256, claimsWithAuthorizedRole)
|
||||||
|
validJWT, err := idToken.SignedString(key)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
session = &sessions.SessionState{
|
||||||
|
AccessToken: "authorized_token",
|
||||||
|
Groups: []string{formatAppRole("Owner")},
|
||||||
|
}
|
||||||
|
session.IDToken = validJWT
|
||||||
|
|
||||||
|
authorized, _ = provider.Authorize(context.Background(), session)
|
||||||
|
assert.True(t, authorized)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,3 +77,18 @@ func formatGroup(rawGroup interface{}) (string, error) {
|
||||||
}
|
}
|
||||||
return string(jsonGroup), nil
|
return string(jsonGroup), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractAccessTokenPayload extracts the access token payload (JSON string in bytes)
|
||||||
|
func extractAccessTokenPayload(s *sessions.SessionState) ([]byte, error) {
|
||||||
|
parts := strings.Split(s.AccessToken, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue