oauth2_proxy/providers/oidc.go
Ryan Luckie 2eecf756e4 Add OIDC support for UserInfo Endpoint Email Verification
* Current OIDC implementation asserts that user email check must come
from JWT token claims. OIDC specification also allows for source
of user email to be fetched from userinfo profile endpoint.
http://openid.net/specs/openid-connect-core-1_0.html#UserInfo

* First, attempt to retrieve email from JWT token claims.  Then fall back to
requesting email from userinfo endpoint.

* Don't fallback to subject for email

https://github.com/bitly/oauth2_proxy/pull/481
2019-07-19 08:53:20 -05:00

182 lines
4.9 KiB
Go

package providers
import (
"context"
"fmt"
"net/http"
"time"
oidc "github.com/coreos/go-oidc"
"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
"github.com/pusher/oauth2_proxy/pkg/requests"
"golang.org/x/oauth2"
)
// OIDCProvider represents an OIDC based Identity Provider
type OIDCProvider struct {
*ProviderData
Verifier *oidc.IDTokenVerifier
AllowUnverifiedEmail bool
}
// NewOIDCProvider initiates a new OIDCProvider
func NewOIDCProvider(p *ProviderData) *OIDCProvider {
p.ProviderName = "OpenID Connect"
return &OIDCProvider{ProviderData: p}
}
// Redeem exchanges the OAuth2 authentication token for an ID token
func (p *OIDCProvider) Redeem(redirectURL, code string) (s *sessions.SessionState, err error) {
ctx := context.Background()
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
RedirectURL: redirectURL,
}
token, err := c.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("token exchange: %v", err)
}
s, err = p.createSessionState(ctx, token)
if err != nil {
return nil, fmt.Errorf("unable to update session: %v", err)
}
return
}
// RefreshSessionIfNeeded checks if the session has expired and uses the
// RefreshToken to fetch a new ID token if required
func (p *OIDCProvider) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) {
if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" {
return false, nil
}
origExpiration := s.ExpiresOn
err := p.redeemRefreshToken(s)
if err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
}
fmt.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration)
return true, nil
}
func (p *OIDCProvider) redeemRefreshToken(s *sessions.SessionState) (err error) {
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
}
ctx := context.Background()
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.createSessionState(ctx, token)
if err != nil {
return fmt.Errorf("unable to update session: %v", err)
}
s.AccessToken = newSession.AccessToken
s.IDToken = newSession.IDToken
s.RefreshToken = newSession.RefreshToken
s.CreatedAt = newSession.CreatedAt
s.ExpiresOn = newSession.ExpiresOn
s.Email = newSession.Email
return
}
func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("token response did not contain an id_token")
}
// Parse and verify ID Token payload.
idToken, err := p.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("could not verify id_token: %v", err)
}
// Extract custom claims.
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Verified *bool `json:"email_verified"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
if claims.Email == "" {
if p.ProfileURL.String() == "" {
return nil, fmt.Errorf("id_token did not contain an email")
}
// If the userinfo endpoint profileURL is defined, then there is a chance the userinfo
// contents at the profileURL contains the email.
// Make a query to the userinfo endpoint, and attempt to locate the email from there.
req, err := http.NewRequest("GET", p.ProfileURL.String(), nil)
if err != nil {
return nil, err
}
req.Header = getOIDCHeader(token.AccessToken)
json, err := requests.Request(req)
if err != nil {
return nil, err
}
email, err := json.Get("email").String()
if err != nil {
return nil, fmt.Errorf("id_token nor userinfo endpoint did not contain an email")
}
claims.Email = email
}
if !p.AllowUnverifiedEmail && claims.Verified != nil && !*claims.Verified {
return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
}
return &sessions.SessionState{
AccessToken: token.AccessToken,
IDToken: rawIDToken,
RefreshToken: token.RefreshToken,
CreatedAt: time.Now(),
ExpiresOn: idToken.Expiry,
Email: claims.Email,
User: claims.Subject,
}, nil
}
// ValidateSessionState checks that the session's IDToken is still valid
func (p *OIDCProvider) ValidateSessionState(s *sessions.SessionState) bool {
ctx := context.Background()
_, err := p.Verifier.Verify(ctx, s.IDToken)
if err != nil {
return false
}
return true
}
func getOIDCHeader(access_token string) http.Header {
header := make(http.Header)
header.Set("Accept", "application/json")
header.Set("Authorization", fmt.Sprintf("Bearer %s", access_token))
return header
}