Files
oauth2-proxy/providers/oidc.go
Apollo3zehnandApollo3zehn b0d87093a8 fix: propagate AdditionalClaims on session refresh (#3547)
AdditionalClaims were only extracted during the initial login. On
cookie refresh, buildSessionFromClaims did extract them into a new
session, but redeemRefreshToken only copied Email, User, Groups and
PreferredUsername back to the existing session, discarding the
AdditionalClaims.

This affects OIDC and MS Entra ID providers. Any header
injection relying on additional claims goes stale until the user
re-authenticates.

Signed-off-by: Apollo3zehn <Apollo3zehn@users.noreply.github.com>
Co-authored-by: Apollo3zehn <Apollo3zehn@users.noreply.github.com>
2026-09-30 17:40:17 +02:00

271 lines
7.4 KiB
Go

package providers
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"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/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr"
"golang.org/x/oauth2"
)
// OIDCProvider represents an OIDC based Identity Provider
type OIDCProvider struct {
*ProviderData
SkipNonce bool
}
const oidcDefaultScope = "openid email profile"
// NewOIDCProvider initiates a new OIDCProvider
func NewOIDCProvider(p *ProviderData, opts options.OIDCOptions) *OIDCProvider {
name := "OpenID Connect"
if p.ProviderName != "" {
name = p.ProviderName
}
oidcProviderDefaults := providerDefaults{
name: name,
loginURL: nil,
redeemURL: nil,
profileURL: nil,
validateURL: nil,
scope: oidcDefaultScope,
}
if len(p.AllowedGroups) > 0 {
oidcProviderDefaults.scope += " groups"
}
p.setProviderDefaults(oidcProviderDefaults)
p.getAuthorizationHeaderFunc = makeOIDCHeader
return &OIDCProvider{
ProviderData: p,
SkipNonce: ptr.Deref(opts.InsecureSkipNonce, options.DefaultInsecureSkipNonce),
}
}
var _ Provider = (*OIDCProvider)(nil)
// GetLoginURL makes the LoginURL with optional nonce support
func (p *OIDCProvider) GetLoginURL(redirectURI, state, nonce string, extraParams url.Values) string {
if !p.SkipNonce {
extraParams.Add("nonce", nonce)
}
// Response mode should only be set if a non default mode is requested
if p.AuthRequestResponseMode != "" {
extraParams.Add("response_mode", p.AuthRequestResponseMode)
}
loginURL := makeLoginURL(p.Data(), redirectURI, state, extraParams)
return loginURL.String()
}
// Redeem exchanges the OAuth2 authentication token for an ID token
func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
clientSecret, err := p.GetClientSecret()
if err != nil {
return nil, err
}
var opts []oauth2.AuthCodeOption
if codeVerifier != "" {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
}
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: clientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
RedirectURL: redirectURL,
}
ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient)
token, err := c.Exchange(ctx, code, opts...)
if err != nil {
return nil, fmt.Errorf("token exchange failed: %v", err)
}
return p.createSession(ctx, token, false)
}
// EnrichSession is called after Redeem to allow providers to enrich session fields
// such as User, Email, Groups with provider specific API calls.
func (p *OIDCProvider) EnrichSession(_ context.Context, s *sessions.SessionState) error {
// If a mandatory email wasn't set, error at this point.
if s.Email == "" {
return errors.New("neither the id_token nor the profileURL set an email")
}
return nil
}
// ValidateSession checks that the session's id_token or access_token (when a ValidateURL is configured) is still valid
func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient)
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
// The ID Token is optional in the Refresh Token Response
if s.Refreshed {
validateEndpointAvailable := p.Data().ValidateURL != nil && p.Data().ValidateURL.String() != ""
if validateEndpointAvailable && !validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) {
logger.Errorf("access_token validation failed")
return false
}
return true
}
if _, err := p.Verifier.Verify(ctx, s.IDToken); err != nil {
logger.Errorf("id_token verification failed: %v", err)
return false
}
if p.SkipNonce {
return true
}
if err := p.checkNonce(s); err != nil {
logger.Errorf("nonce verification failed: %v", err)
return false
}
return true
}
// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
func (p *OIDCProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
if s == nil || s.RefreshToken == "" {
return false, nil
}
ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient)
err := p.redeemRefreshToken(ctx, s)
if err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
}
return true, nil
}
// redeemRefreshToken uses a RefreshToken with the RedeemURL to refresh the
// Access Token and (optionally) the ID Token.
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
clientSecret, err := p.GetClientSecret()
if err != nil {
return err
}
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: clientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
}
t := &oauth2.Token{
RefreshToken: s.RefreshToken,
Expiry: time.Now().Add(-time.Hour),
}
token, err := c.TokenSource(ctx, t).Token()
if err != nil {
return fmt.Errorf("failed to get token: %v", err)
}
newSession, err := p.createSession(ctx, token, true)
if err != nil {
return fmt.Errorf("unable create new session state from response: %v", err)
}
// It's possible that a refresh does not renew the ID Token.
// If it doesn't, it's probably better to retain the old one.
if newSession.IDToken != "" {
s.IDToken = newSession.IDToken
s.Email = newSession.Email
s.User = newSession.User
s.Groups = newSession.Groups
s.PreferredUsername = newSession.PreferredUsername
s.AdditionalClaims = newSession.AdditionalClaims
}
s.AccessToken = newSession.AccessToken
s.RefreshToken = newSession.RefreshToken
s.CreatedAt = newSession.CreatedAt
s.ExpiresOn = newSession.ExpiresOn
s.Refreshed = newSession.Refreshed
return nil
}
// CreateSessionFromToken converts Bearer IDTokens into sessions
func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient)
idToken, err := p.Verifier.Verify(ctx, token)
if err != nil {
return nil, err
}
ss, err := p.buildSessionFromClaims(token, "")
if err != nil {
return nil, err
}
// Allow empty Email in Bearer case since we can't hit the ProfileURL
if ss.Email == "" {
ss.Email = ss.User
}
ss.AccessToken = token
ss.IDToken = token
ss.RefreshToken = ""
ss.CreatedAtNow()
ss.SetExpiresOn(idToken.Expiry)
return ss, nil
}
// createSession takes an oauth2.Token and creates a SessionState from it.
// It alters behavior if called from Redeem vs Refresh
func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
_, err := p.verifyIDToken(ctx, token)
if err != nil {
switch err {
case ErrMissingIDToken:
// IDToken is mandatory in Redeem but optional in Refresh
if !refresh {
return nil, errors.New("token response did not contain an id_token")
}
default:
return nil, fmt.Errorf("could not verify id_token: %v", err)
}
}
rawIDToken := getIDToken(token)
ss, err := p.buildSessionFromClaims(rawIDToken, token.AccessToken)
if err != nil {
return nil, err
}
ss.AccessToken = token.AccessToken
ss.RefreshToken = token.RefreshToken
ss.IDToken = rawIDToken
ss.CreatedAtNow()
ss.SetExpiresOn(token.Expiry)
ss.Refreshed = refresh
return ss, nil
}