mirror of https://github.com/status-im/consul.git
328 lines
11 KiB
Go
328 lines
11 KiB
Go
|
package oidc
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"encoding/base64"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io/ioutil"
|
||
|
"net/http"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"golang.org/x/oauth2"
|
||
|
jose "gopkg.in/square/go-jose.v2"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
issuerGoogleAccounts = "https://accounts.google.com"
|
||
|
issuerGoogleAccountsNoScheme = "accounts.google.com"
|
||
|
)
|
||
|
|
||
|
// KeySet is a set of publc JSON Web Keys that can be used to validate the signature
|
||
|
// of JSON web tokens. This is expected to be backed by a remote key set through
|
||
|
// provider metadata discovery or an in-memory set of keys delivered out-of-band.
|
||
|
type KeySet interface {
|
||
|
// VerifySignature parses the JSON web token, verifies the signature, and returns
|
||
|
// the raw payload. Header and claim fields are validated by other parts of the
|
||
|
// package. For example, the KeySet does not need to check values such as signature
|
||
|
// algorithm, issuer, and audience since the IDTokenVerifier validates these values
|
||
|
// independently.
|
||
|
//
|
||
|
// If VerifySignature makes HTTP requests to verify the token, it's expected to
|
||
|
// use any HTTP client associated with the context through ClientContext.
|
||
|
VerifySignature(ctx context.Context, jwt string) (payload []byte, err error)
|
||
|
}
|
||
|
|
||
|
// IDTokenVerifier provides verification for ID Tokens.
|
||
|
type IDTokenVerifier struct {
|
||
|
keySet KeySet
|
||
|
config *Config
|
||
|
issuer string
|
||
|
}
|
||
|
|
||
|
// NewVerifier returns a verifier manually constructed from a key set and issuer URL.
|
||
|
//
|
||
|
// It's easier to use provider discovery to construct an IDTokenVerifier than creating
|
||
|
// one directly. This method is intended to be used with provider that don't support
|
||
|
// metadata discovery, or avoiding round trips when the key set URL is already known.
|
||
|
//
|
||
|
// This constructor can be used to create a verifier directly using the issuer URL and
|
||
|
// JSON Web Key Set URL without using discovery:
|
||
|
//
|
||
|
// keySet := oidc.NewRemoteKeySet(ctx, "https://www.googleapis.com/oauth2/v3/certs")
|
||
|
// verifier := oidc.NewVerifier("https://accounts.google.com", keySet, config)
|
||
|
//
|
||
|
// Since KeySet is an interface, this constructor can also be used to supply custom
|
||
|
// public key sources. For example, if a user wanted to supply public keys out-of-band
|
||
|
// and hold them statically in-memory:
|
||
|
//
|
||
|
// // Custom KeySet implementation.
|
||
|
// keySet := newStatisKeySet(publicKeys...)
|
||
|
//
|
||
|
// // Verifier uses the custom KeySet implementation.
|
||
|
// verifier := oidc.NewVerifier("https://auth.example.com", keySet, config)
|
||
|
//
|
||
|
func NewVerifier(issuerURL string, keySet KeySet, config *Config) *IDTokenVerifier {
|
||
|
return &IDTokenVerifier{keySet: keySet, config: config, issuer: issuerURL}
|
||
|
}
|
||
|
|
||
|
// Config is the configuration for an IDTokenVerifier.
|
||
|
type Config struct {
|
||
|
// Expected audience of the token. For a majority of the cases this is expected to be
|
||
|
// the ID of the client that initialized the login flow. It may occasionally differ if
|
||
|
// the provider supports the authorizing party (azp) claim.
|
||
|
//
|
||
|
// If not provided, users must explicitly set SkipClientIDCheck.
|
||
|
ClientID string
|
||
|
// If specified, only this set of algorithms may be used to sign the JWT.
|
||
|
//
|
||
|
// Since many providers only support RS256, SupportedSigningAlgs defaults to this value.
|
||
|
SupportedSigningAlgs []string
|
||
|
|
||
|
// If true, no ClientID check performed. Must be true if ClientID field is empty.
|
||
|
SkipClientIDCheck bool
|
||
|
// If true, token expiry is not checked.
|
||
|
SkipExpiryCheck bool
|
||
|
|
||
|
// SkipIssuerCheck is intended for specialized cases where the the caller wishes to
|
||
|
// defer issuer validation. When enabled, callers MUST independently verify the Token's
|
||
|
// Issuer is a known good value.
|
||
|
//
|
||
|
// Mismatched issuers often indicate client mis-configuration. If mismatches are
|
||
|
// unexpected, evaluate if the provided issuer URL is incorrect instead of enabling
|
||
|
// this option.
|
||
|
SkipIssuerCheck bool
|
||
|
|
||
|
// Time function to check Token expiry. Defaults to time.Now
|
||
|
Now func() time.Time
|
||
|
}
|
||
|
|
||
|
// Verifier returns an IDTokenVerifier that uses the provider's key set to verify JWTs.
|
||
|
//
|
||
|
// The returned IDTokenVerifier is tied to the Provider's context and its behavior is
|
||
|
// undefined once the Provider's context is canceled.
|
||
|
func (p *Provider) Verifier(config *Config) *IDTokenVerifier {
|
||
|
return NewVerifier(p.issuer, p.remoteKeySet, config)
|
||
|
}
|
||
|
|
||
|
func parseJWT(p string) ([]byte, error) {
|
||
|
parts := strings.Split(p, ".")
|
||
|
if len(parts) < 2 {
|
||
|
return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts))
|
||
|
}
|
||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err)
|
||
|
}
|
||
|
return payload, nil
|
||
|
}
|
||
|
|
||
|
func contains(sli []string, ele string) bool {
|
||
|
for _, s := range sli {
|
||
|
if s == ele {
|
||
|
return true
|
||
|
}
|
||
|
}
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
// Returns the Claims from the distributed JWT token
|
||
|
func resolveDistributedClaim(ctx context.Context, verifier *IDTokenVerifier, src claimSource) ([]byte, error) {
|
||
|
req, err := http.NewRequest("GET", src.Endpoint, nil)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("malformed request: %v", err)
|
||
|
}
|
||
|
if src.AccessToken != "" {
|
||
|
req.Header.Set("Authorization", "Bearer "+src.AccessToken)
|
||
|
}
|
||
|
|
||
|
resp, err := doRequest(ctx, req)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("oidc: Request to endpoint failed: %v", err)
|
||
|
}
|
||
|
defer resp.Body.Close()
|
||
|
|
||
|
body, err := ioutil.ReadAll(resp.Body)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("unable to read response body: %v", err)
|
||
|
}
|
||
|
|
||
|
if resp.StatusCode != http.StatusOK {
|
||
|
return nil, fmt.Errorf("oidc: request failed: %v", resp.StatusCode)
|
||
|
}
|
||
|
|
||
|
token, err := verifier.Verify(ctx, string(body))
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("malformed response body: %v", err)
|
||
|
}
|
||
|
|
||
|
return token.claims, nil
|
||
|
}
|
||
|
|
||
|
func parseClaim(raw []byte, name string, v interface{}) error {
|
||
|
var parsed map[string]json.RawMessage
|
||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
val, ok := parsed[name]
|
||
|
if !ok {
|
||
|
return fmt.Errorf("claim doesn't exist: %s", name)
|
||
|
}
|
||
|
|
||
|
return json.Unmarshal([]byte(val), v)
|
||
|
}
|
||
|
|
||
|
// Verify parses a raw ID Token, verifies it's been signed by the provider, preforms
|
||
|
// any additional checks depending on the Config, and returns the payload.
|
||
|
//
|
||
|
// Verify does NOT do nonce validation, which is the callers responsibility.
|
||
|
//
|
||
|
// See: https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||
|
//
|
||
|
// oauth2Token, err := oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
|
||
|
// if err != nil {
|
||
|
// // handle error
|
||
|
// }
|
||
|
//
|
||
|
// // Extract the ID Token from oauth2 token.
|
||
|
// rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||
|
// if !ok {
|
||
|
// // handle error
|
||
|
// }
|
||
|
//
|
||
|
// token, err := verifier.Verify(ctx, rawIDToken)
|
||
|
//
|
||
|
func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDToken, error) {
|
||
|
jws, err := jose.ParseSigned(rawIDToken)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
|
||
|
}
|
||
|
|
||
|
// Throw out tokens with invalid claims before trying to verify the token. This lets
|
||
|
// us do cheap checks before possibly re-syncing keys.
|
||
|
payload, err := parseJWT(rawIDToken)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
|
||
|
}
|
||
|
var token idToken
|
||
|
if err := json.Unmarshal(payload, &token); err != nil {
|
||
|
return nil, fmt.Errorf("oidc: failed to unmarshal claims: %v", err)
|
||
|
}
|
||
|
|
||
|
distributedClaims := make(map[string]claimSource)
|
||
|
|
||
|
//step through the token to map claim names to claim sources"
|
||
|
for cn, src := range token.ClaimNames {
|
||
|
if src == "" {
|
||
|
return nil, fmt.Errorf("oidc: failed to obtain source from claim name")
|
||
|
}
|
||
|
s, ok := token.ClaimSources[src]
|
||
|
if !ok {
|
||
|
return nil, fmt.Errorf("oidc: source does not exist")
|
||
|
}
|
||
|
distributedClaims[cn] = s
|
||
|
}
|
||
|
|
||
|
t := &IDToken{
|
||
|
Issuer: token.Issuer,
|
||
|
Subject: token.Subject,
|
||
|
Audience: []string(token.Audience),
|
||
|
Expiry: time.Time(token.Expiry),
|
||
|
IssuedAt: time.Time(token.IssuedAt),
|
||
|
Nonce: token.Nonce,
|
||
|
AccessTokenHash: token.AtHash,
|
||
|
claims: payload,
|
||
|
distributedClaims: distributedClaims,
|
||
|
}
|
||
|
|
||
|
// Check issuer.
|
||
|
if !v.config.SkipIssuerCheck && t.Issuer != v.issuer {
|
||
|
// Google sometimes returns "accounts.google.com" as the issuer claim instead of
|
||
|
// the required "https://accounts.google.com". Detect this case and allow it only
|
||
|
// for Google.
|
||
|
//
|
||
|
// We will not add hooks to let other providers go off spec like this.
|
||
|
if !(v.issuer == issuerGoogleAccounts && t.Issuer == issuerGoogleAccountsNoScheme) {
|
||
|
return nil, fmt.Errorf("oidc: id token issued by a different provider, expected %q got %q", v.issuer, t.Issuer)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If a client ID has been provided, make sure it's part of the audience. SkipClientIDCheck must be true if ClientID is empty.
|
||
|
//
|
||
|
// This check DOES NOT ensure that the ClientID is the party to which the ID Token was issued (i.e. Authorized party).
|
||
|
if !v.config.SkipClientIDCheck {
|
||
|
if v.config.ClientID != "" {
|
||
|
if !contains(t.Audience, v.config.ClientID) {
|
||
|
return nil, fmt.Errorf("oidc: expected audience %q got %q", v.config.ClientID, t.Audience)
|
||
|
}
|
||
|
} else {
|
||
|
return nil, fmt.Errorf("oidc: invalid configuration, clientID must be provided or SkipClientIDCheck must be set")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If a SkipExpiryCheck is false, make sure token is not expired.
|
||
|
if !v.config.SkipExpiryCheck {
|
||
|
now := time.Now
|
||
|
if v.config.Now != nil {
|
||
|
now = v.config.Now
|
||
|
}
|
||
|
nowTime := now()
|
||
|
|
||
|
if t.Expiry.Before(nowTime) {
|
||
|
return nil, fmt.Errorf("oidc: token is expired (Token Expiry: %v)", t.Expiry)
|
||
|
}
|
||
|
|
||
|
// If nbf claim is provided in token, ensure that it is indeed in the past.
|
||
|
if token.NotBefore != nil {
|
||
|
nbfTime := time.Time(*token.NotBefore)
|
||
|
leeway := 1 * time.Minute
|
||
|
|
||
|
if nowTime.Add(leeway).Before(nbfTime) {
|
||
|
return nil, fmt.Errorf("oidc: current time %v before the nbf (not before) time: %v", nowTime, nbfTime)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
switch len(jws.Signatures) {
|
||
|
case 0:
|
||
|
return nil, fmt.Errorf("oidc: id token not signed")
|
||
|
case 1:
|
||
|
default:
|
||
|
return nil, fmt.Errorf("oidc: multiple signatures on id token not supported")
|
||
|
}
|
||
|
|
||
|
sig := jws.Signatures[0]
|
||
|
supportedSigAlgs := v.config.SupportedSigningAlgs
|
||
|
if len(supportedSigAlgs) == 0 {
|
||
|
supportedSigAlgs = []string{RS256}
|
||
|
}
|
||
|
|
||
|
if !contains(supportedSigAlgs, sig.Header.Algorithm) {
|
||
|
return nil, fmt.Errorf("oidc: id token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm)
|
||
|
}
|
||
|
|
||
|
t.sigAlgorithm = sig.Header.Algorithm
|
||
|
|
||
|
gotPayload, err := v.keySet.VerifySignature(ctx, rawIDToken)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("failed to verify signature: %v", err)
|
||
|
}
|
||
|
|
||
|
// Ensure that the payload returned by the square actually matches the payload parsed earlier.
|
||
|
if !bytes.Equal(gotPayload, payload) {
|
||
|
return nil, errors.New("oidc: internal error, payload parsed did not match previous payload")
|
||
|
}
|
||
|
|
||
|
return t, nil
|
||
|
}
|
||
|
|
||
|
// Nonce returns an auth code option which requires the ID Token created by the
|
||
|
// OpenID Connect provider to contain the specified nonce.
|
||
|
func Nonce(nonce string) oauth2.AuthCodeOption {
|
||
|
return oauth2.SetAuthURLParam("nonce", nonce)
|
||
|
}
|