ea8ab90968
Co-authored-by: R.B. Boyer <4903+rboyer@users.noreply.github.com>
286 lines
8.7 KiB
Go
286 lines
8.7 KiB
Go
package oidcauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc"
|
|
"github.com/hashicorp/go-uuid"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
var (
|
|
oidcStateTimeout = 10 * time.Minute
|
|
oidcStateCleanupInterval = 1 * time.Minute
|
|
)
|
|
|
|
// GetAuthCodeURL is the first part of the OIDC authorization code workflow.
|
|
// The statePayload field is stored in the Authenticator instance keyed by the
|
|
// "state" key so it can be returned during a future call to
|
|
// ClaimsFromAuthCode.
|
|
//
|
|
// Requires the authenticator's config type be set to 'oidc'.
|
|
func (a *Authenticator) GetAuthCodeURL(ctx context.Context, redirectURI string, statePayload interface{}) (string, error) {
|
|
if a.config.authType() != authOIDCFlow {
|
|
return "", fmt.Errorf("GetAuthCodeURL is incompatible with type %q", TypeJWT)
|
|
}
|
|
if redirectURI == "" {
|
|
return "", errors.New("missing redirect_uri")
|
|
}
|
|
|
|
if !validRedirect(redirectURI, a.config.AllowedRedirectURIs) {
|
|
return "", fmt.Errorf("unauthorized redirect_uri: %s", redirectURI)
|
|
}
|
|
|
|
// "openid" is a required scope for OpenID Connect flows
|
|
scopes := append([]string{oidc.ScopeOpenID}, a.config.OIDCScopes...)
|
|
|
|
// Configure an OpenID Connect aware OAuth2 client
|
|
oauth2Config := oauth2.Config{
|
|
ClientID: a.config.OIDCClientID,
|
|
ClientSecret: a.config.OIDCClientSecret,
|
|
RedirectURL: redirectURI,
|
|
Endpoint: a.provider.Endpoint(),
|
|
Scopes: scopes,
|
|
}
|
|
|
|
stateID, nonce, err := a.createOIDCState(redirectURI, statePayload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error generating OAuth state: %v", err)
|
|
}
|
|
|
|
authCodeOpts := []oauth2.AuthCodeOption{
|
|
oidc.Nonce(nonce),
|
|
}
|
|
if len(a.config.OIDCACRValues) > 0 {
|
|
authCodeOpts = append(authCodeOpts, oauth2.SetAuthURLParam("acr_values", strings.Join(a.config.OIDCACRValues, " ")))
|
|
}
|
|
|
|
return oauth2Config.AuthCodeURL(stateID, authCodeOpts...), nil
|
|
}
|
|
|
|
// ClaimsFromAuthCode is the second part of the OIDC authorization code
|
|
// workflow. The interface{} return value is the statePayload previously passed
|
|
// via GetAuthCodeURL.
|
|
//
|
|
// The error may be of type *ProviderLoginFailedError or
|
|
// *TokenVerificationFailedError which can be detected via errors.As().
|
|
//
|
|
// Requires the authenticator's config type be set to 'oidc'.
|
|
func (a *Authenticator) ClaimsFromAuthCode(ctx context.Context, stateParam, code string) (*Claims, interface{}, error) {
|
|
if a.config.authType() != authOIDCFlow {
|
|
return nil, nil, fmt.Errorf("ClaimsFromAuthCode is incompatible with type %q", TypeJWT)
|
|
}
|
|
|
|
// TODO(sso): this could be because we ACTUALLY are getting OIDC error responses and
|
|
// should handle them elsewhere!
|
|
if code == "" {
|
|
return nil, nil, &ProviderLoginFailedError{
|
|
Err: fmt.Errorf("OAuth code parameter not provided"),
|
|
}
|
|
}
|
|
|
|
state := a.verifyOIDCState(stateParam)
|
|
if state == nil {
|
|
return nil, nil, &ProviderLoginFailedError{
|
|
Err: fmt.Errorf("Expired or missing OAuth state."),
|
|
}
|
|
}
|
|
|
|
oidcCtx := contextWithHttpClient(ctx, a.httpClient)
|
|
|
|
var oauth2Config = oauth2.Config{
|
|
ClientID: a.config.OIDCClientID,
|
|
ClientSecret: a.config.OIDCClientSecret,
|
|
RedirectURL: state.redirectURI,
|
|
Endpoint: a.provider.Endpoint(),
|
|
Scopes: []string{oidc.ScopeOpenID},
|
|
}
|
|
|
|
oauth2Token, err := oauth2Config.Exchange(oidcCtx, code)
|
|
if err != nil {
|
|
return nil, nil, &ProviderLoginFailedError{
|
|
Err: fmt.Errorf("Error exchanging oidc code: %w", err),
|
|
}
|
|
}
|
|
|
|
// Extract the ID Token from OAuth2 token.
|
|
rawToken, ok := oauth2Token.Extra("id_token").(string)
|
|
if !ok {
|
|
return nil, nil, &TokenVerificationFailedError{
|
|
Err: errors.New("No id_token found in response."),
|
|
}
|
|
}
|
|
|
|
if a.config.VerboseOIDCLogging && a.logger != nil {
|
|
a.logger.Debug("OIDC provider response", "ID token", rawToken)
|
|
}
|
|
|
|
// Parse and verify ID Token payload.
|
|
allClaims, err := a.verifyOIDCToken(ctx, rawToken) // TODO(sso): should this use oidcCtx?
|
|
if err != nil {
|
|
return nil, nil, &TokenVerificationFailedError{
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
if allClaims["nonce"] != state.nonce { // TODO(sso): does this need a cast?
|
|
return nil, nil, &TokenVerificationFailedError{
|
|
Err: errors.New("Invalid ID token nonce."),
|
|
}
|
|
}
|
|
delete(allClaims, "nonce")
|
|
|
|
// Attempt to fetch information from the /userinfo endpoint and merge it with
|
|
// the existing claims data. A failure to fetch additional information from this
|
|
// endpoint will not invalidate the authorization flow.
|
|
if userinfo, err := a.provider.UserInfo(oidcCtx, oauth2.StaticTokenSource(oauth2Token)); err == nil {
|
|
_ = userinfo.Claims(&allClaims)
|
|
} else {
|
|
if a.logger != nil {
|
|
logFunc := a.logger.Warn
|
|
if strings.Contains(err.Error(), "user info endpoint is not supported") {
|
|
logFunc = a.logger.Info
|
|
}
|
|
logFunc("error reading /userinfo endpoint", "error", err)
|
|
}
|
|
}
|
|
|
|
if a.config.VerboseOIDCLogging && a.logger != nil {
|
|
if c, err := json.Marshal(allClaims); err == nil {
|
|
a.logger.Debug("OIDC provider response", "claims", string(c))
|
|
} else {
|
|
a.logger.Debug("OIDC provider response", "marshalling error", err.Error())
|
|
}
|
|
}
|
|
|
|
c, err := a.extractClaims(allClaims)
|
|
if err != nil {
|
|
return nil, nil, &TokenVerificationFailedError{
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
if a.config.VerboseOIDCLogging && a.logger != nil {
|
|
a.logger.Debug("OIDC provider response", "extracted_claims", c)
|
|
}
|
|
|
|
return c, state.payload, nil
|
|
}
|
|
|
|
// ProviderLoginFailedError is an error type sometimes returned from
|
|
// ClaimsFromAuthCode().
|
|
//
|
|
// It represents a failure to complete the authorization code workflow with the
|
|
// provider such as losing important OIDC parameters or a failure to fetch an
|
|
// id_token.
|
|
//
|
|
// You can check for it with errors.As().
|
|
type ProviderLoginFailedError struct {
|
|
Err error
|
|
}
|
|
|
|
func (e *ProviderLoginFailedError) Error() string {
|
|
return fmt.Sprintf("Provider login failed: %v", e.Err)
|
|
}
|
|
|
|
func (e *ProviderLoginFailedError) Unwrap() error { return e.Err }
|
|
|
|
// TokenVerificationFailedError is an error type sometimes returned from
|
|
// ClaimsFromAuthCode().
|
|
//
|
|
// It represents a failure to vet the returned OIDC credentials for validity
|
|
// such as the id_token not passing verification or using an mismatched nonce.
|
|
//
|
|
// You can check for it with errors.As().
|
|
type TokenVerificationFailedError struct {
|
|
Err error
|
|
}
|
|
|
|
func (e *TokenVerificationFailedError) Error() string {
|
|
return fmt.Sprintf("Token verification failed: %v", e.Err)
|
|
}
|
|
|
|
func (e *TokenVerificationFailedError) Unwrap() error { return e.Err }
|
|
|
|
func (a *Authenticator) verifyOIDCToken(ctx context.Context, rawToken string) (map[string]interface{}, error) {
|
|
allClaims := make(map[string]interface{})
|
|
|
|
oidcConfig := &oidc.Config{
|
|
SupportedSigningAlgs: a.config.JWTSupportedAlgs,
|
|
}
|
|
switch a.config.authType() {
|
|
case authOIDCFlow:
|
|
oidcConfig.ClientID = a.config.OIDCClientID
|
|
case authOIDCDiscovery:
|
|
oidcConfig.SkipClientIDCheck = true
|
|
default:
|
|
return nil, fmt.Errorf("unsupported auth type for this verifyOIDCToken: %d", a.config.authType())
|
|
}
|
|
|
|
verifier := a.provider.Verifier(oidcConfig)
|
|
|
|
idToken, err := verifier.Verify(ctx, rawToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error validating signature: %v", err)
|
|
}
|
|
|
|
if err := idToken.Claims(&allClaims); err != nil {
|
|
return nil, fmt.Errorf("unable to successfully parse all claims from token: %v", err)
|
|
}
|
|
// TODO(sso): why isn't this strict for OIDC?
|
|
if err := validateAudience(a.config.BoundAudiences, idToken.Audience, false); err != nil {
|
|
return nil, fmt.Errorf("error validating claims: %v", err)
|
|
}
|
|
|
|
return allClaims, nil
|
|
}
|
|
|
|
// verifyOIDCState tests whether the provided state ID is valid and returns the
|
|
// associated state object if so. A nil state is returned if the ID is not found
|
|
// or expired. The state should only ever be retrieved once and is deleted as
|
|
// part of this request.
|
|
func (a *Authenticator) verifyOIDCState(stateID string) *oidcState {
|
|
defer a.oidcStates.Delete(stateID)
|
|
|
|
if stateRaw, ok := a.oidcStates.Get(stateID); ok {
|
|
return stateRaw.(*oidcState)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createOIDCState make an expiring state object, associated with a random state ID
|
|
// that is passed throughout the OAuth process. A nonce is also included in the
|
|
// auth process, and for simplicity will be identical in length/format as the state ID.
|
|
func (a *Authenticator) createOIDCState(redirectURI string, payload interface{}) (string, string, error) {
|
|
// Get enough bytes for 2 160-bit IDs (per rfc6749#section-10.10)
|
|
bytes, err := uuid.GenerateRandomBytes(2 * 20)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
stateID := fmt.Sprintf("%x", bytes[:20])
|
|
nonce := fmt.Sprintf("%x", bytes[20:])
|
|
|
|
a.oidcStates.SetDefault(stateID, &oidcState{
|
|
nonce: nonce,
|
|
redirectURI: redirectURI,
|
|
payload: payload,
|
|
})
|
|
|
|
return stateID, nonce, nil
|
|
}
|
|
|
|
// oidcState is created when an authURL is requested. The state
|
|
// identifier is passed throughout the OAuth process.
|
|
type oidcState struct {
|
|
nonce string
|
|
redirectURI string
|
|
payload interface{}
|
|
}
|