initial commit
This commit is contained in:
commit
6d0890e280
68 changed files with 5986 additions and 0 deletions
287
pkg/rp/default_rp.go
Normal file
287
pkg/rp/default_rp.go
Normal file
|
@ -0,0 +1,287 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc/grants"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
grants_tx "github.com/caos/oidc/pkg/oidc/grants/tokenexchange"
|
||||
"github.com/caos/oidc/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
idTokenKey = "id_token"
|
||||
stateParam = "state"
|
||||
pkceCode = "pkce"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultErrorHandler = func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) {
|
||||
http.Error(w, errorType+": "+errorDesc, http.StatusInternalServerError)
|
||||
}
|
||||
)
|
||||
|
||||
//DefaultRP impements the `DelegationTokenExchangeRP` interface extending the `RelayingParty` interface
|
||||
type DefaultRP struct {
|
||||
endpoints Endpoints
|
||||
|
||||
oauthConfig oauth2.Config
|
||||
config *Config
|
||||
pkce bool
|
||||
|
||||
httpClient *http.Client
|
||||
cookieHandler *utils.CookieHandler
|
||||
|
||||
errorHandler func(http.ResponseWriter, *http.Request, string, string, string)
|
||||
|
||||
verifier Verifier
|
||||
}
|
||||
|
||||
//NewDefaultRP creates `DefaultRP` with the given
|
||||
//Config and possible configOptions
|
||||
//it will run discovery on the provided issuer
|
||||
//if no verifier is provided using the options the `DefaultVerifier` is set
|
||||
func NewDefaultRP(rpConfig *Config, rpOpts ...DefaultRPOpts) (DelegationTokenExchangeRP, error) {
|
||||
p := &DefaultRP{
|
||||
config: rpConfig,
|
||||
httpClient: utils.DefaultHTTPClient,
|
||||
}
|
||||
|
||||
for _, optFunc := range rpOpts {
|
||||
optFunc(p)
|
||||
}
|
||||
|
||||
if err := p.discover(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.errorHandler == nil {
|
||||
p.errorHandler = DefaultErrorHandler
|
||||
}
|
||||
|
||||
if p.verifier == nil {
|
||||
p.verifier = NewDefaultVerifier(rpConfig.Issuer, rpConfig.ClientID, NewRemoteKeySet(p.httpClient, p.endpoints.JKWsURL))
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
//DefaultRPOpts is the type for providing dynamic options to the DefaultRP
|
||||
type DefaultRPOpts func(p *DefaultRP)
|
||||
|
||||
//WithCookieHandler set a `CookieHandler` for securing the various redirects
|
||||
func WithCookieHandler(cookieHandler *utils.CookieHandler) DefaultRPOpts {
|
||||
return func(p *DefaultRP) {
|
||||
p.cookieHandler = cookieHandler
|
||||
}
|
||||
}
|
||||
|
||||
//WithPKCE sets the RP to use PKCE (oauth2 code challenge)
|
||||
//it also sets a `CookieHandler` for securing the various redirects
|
||||
//and exchanging the code challenge
|
||||
func WithPKCE(cookieHandler *utils.CookieHandler) DefaultRPOpts {
|
||||
return func(p *DefaultRP) {
|
||||
p.pkce = true
|
||||
p.cookieHandler = cookieHandler
|
||||
}
|
||||
}
|
||||
|
||||
//WithHTTPClient provides the ability to set an http client to be used for the relaying party and verifier
|
||||
func WithHTTPClient(client *http.Client) DefaultRPOpts {
|
||||
return func(p *DefaultRP) {
|
||||
p.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
//AuthURL is the `RelayingParty` interface implementation
|
||||
//wrapping the oauth2 `AuthCodeURL`
|
||||
//returning the url of the auth request
|
||||
func (p *DefaultRP) AuthURL(state string, opts ...AuthURLOpt) string {
|
||||
authOpts := make([]oauth2.AuthCodeOption, 0)
|
||||
for _, opt := range opts {
|
||||
authOpts = append(authOpts, opt()...)
|
||||
}
|
||||
return p.oauthConfig.AuthCodeURL(state, authOpts...)
|
||||
}
|
||||
|
||||
//AuthURL is the `RelayingParty` interface implementation
|
||||
//extending the `AuthURL` method with a http redirect handler
|
||||
func (p *DefaultRP) AuthURLHandler(state string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
opts := make([]AuthURLOpt, 0)
|
||||
if err := p.trySetStateCookie(w, state); err != nil {
|
||||
http.Error(w, "failed to create state cookie: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if p.pkce {
|
||||
codeChallenge, err := p.generateAndStoreCodeChallenge(w)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create code challenge: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
opts = append(opts, WithCodeChallenge(codeChallenge))
|
||||
}
|
||||
http.Redirect(w, r, p.AuthURL(state, opts...), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DefaultRP) generateAndStoreCodeChallenge(w http.ResponseWriter) (string, error) {
|
||||
var codeVerifier string
|
||||
codeVerifier = "s"
|
||||
if err := p.cookieHandler.SetCookie(w, pkceCode, codeVerifier); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return oidc.NewSHACodeChallenge(codeVerifier), nil
|
||||
}
|
||||
|
||||
//AuthURL is the `RelayingParty` interface implementation
|
||||
//handling the oauth2 code exchange, extracting and validating the id_token
|
||||
//returning it paresed together with the oauth2 tokens (access, refresh)
|
||||
func (p *DefaultRP) CodeExchange(ctx context.Context, code string, opts ...CodeExchangeOpt) (tokens *oidc.Tokens, err error) {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, p.httpClient)
|
||||
codeOpts := make([]oauth2.AuthCodeOption, 0)
|
||||
for _, opt := range opts {
|
||||
codeOpts = append(codeOpts, opt()...)
|
||||
}
|
||||
|
||||
token, err := p.oauthConfig.Exchange(ctx, code, codeOpts...)
|
||||
if err != nil {
|
||||
return nil, err //TODO: our error
|
||||
}
|
||||
idTokenString, ok := token.Extra(idTokenKey).(string)
|
||||
if !ok {
|
||||
//TODO: implement
|
||||
}
|
||||
|
||||
idToken, err := p.verifier.Verify(ctx, token.AccessToken, idTokenString)
|
||||
if err != nil {
|
||||
return nil, err //TODO: err
|
||||
}
|
||||
|
||||
return &oidc.Tokens{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil
|
||||
}
|
||||
|
||||
//AuthURL is the `RelayingParty` interface implementation
|
||||
//extending the `CodeExchange` method with callback function
|
||||
func (p *DefaultRP) CodeExchangeHandler(callback func(http.ResponseWriter, *http.Request, *oidc.Tokens, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := p.tryReadStateCookie(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get state: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
params := r.URL.Query()
|
||||
if params.Get("error") != "" {
|
||||
p.errorHandler(w, r, params.Get("error"), params.Get("error_description"), state)
|
||||
return
|
||||
}
|
||||
codeOpts := make([]CodeExchangeOpt, 0)
|
||||
if p.pkce {
|
||||
codeVerifier, err := p.cookieHandler.CheckCookie(r, pkceCode)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get code verifier: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
codeOpts = append(codeOpts, WithCodeVerifier(codeVerifier))
|
||||
}
|
||||
tokens, err := p.CodeExchange(r.Context(), params.Get("code"), codeOpts...)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to exchange token: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
callback(w, r, tokens, state)
|
||||
}
|
||||
}
|
||||
|
||||
// func (p *DefaultRP) Introspect(ctx context.Context, accessToken string) (oidc.TokenIntrospectResponse, error) {
|
||||
// // req := &http.Request{}
|
||||
// // resp, err := p.httpClient.Do(req)
|
||||
// // if err != nil {
|
||||
|
||||
// // }
|
||||
// // p.endpoints.IntrospectURL
|
||||
// return nil, nil
|
||||
// }
|
||||
|
||||
func (p *DefaultRP) Userinfo() {}
|
||||
|
||||
//ClientCredentials is the `RelayingParty` interface implementation
|
||||
//handling the oauth2 client credentials grant
|
||||
func (p *DefaultRP) ClientCredentials(ctx context.Context, scopes ...string) (newToken *oauth2.Token, err error) {
|
||||
return p.callTokenEndpoint(grants.ClientCredentialsGrantBasic(scopes...))
|
||||
}
|
||||
|
||||
//TokenExchange is the `TokenExchangeRP` interface implementation
|
||||
//handling the oauth2 token exchange (draft)
|
||||
func (p *DefaultRP) TokenExchange(ctx context.Context, request *grants_tx.TokenExchangeRequest) (newToken *oauth2.Token, err error) {
|
||||
return p.callTokenEndpoint(request)
|
||||
}
|
||||
|
||||
//DelegationTokenExchange is the `TokenExchangeRP` interface implementation
|
||||
//handling the oauth2 token exchange for a delegation token (draft)
|
||||
func (p *DefaultRP) DelegationTokenExchange(ctx context.Context, subjectToken string, reqOpts ...grants_tx.TokenExchangeOption) (newToken *oauth2.Token, err error) {
|
||||
return p.TokenExchange(ctx, DelegationTokenRequest(subjectToken, reqOpts...))
|
||||
}
|
||||
|
||||
func (p *DefaultRP) discover() error {
|
||||
wellKnown := strings.TrimSuffix(p.config.Issuer, "/") + oidc.DiscoveryEndpoint
|
||||
req, err := http.NewRequest("GET", wellKnown, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discoveryConfig := new(oidc.DiscoveryConfiguration)
|
||||
err = utils.HttpRequest(p.httpClient, req, &discoveryConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.endpoints = GetEndpoints(discoveryConfig)
|
||||
p.oauthConfig = oauth2.Config{
|
||||
ClientID: p.config.ClientID,
|
||||
ClientSecret: p.config.ClientSecret,
|
||||
Endpoint: p.endpoints.Endpoint,
|
||||
RedirectURL: p.config.CallbackURL,
|
||||
Scopes: p.config.Scopes,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DefaultRP) callTokenEndpoint(request interface{}) (newToken *oauth2.Token, err error) {
|
||||
req, err := utils.FormRequest(p.endpoints.TokenURL, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(p.config.ClientID + ":" + p.config.ClientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
token := new(oauth2.Token)
|
||||
if err := utils.HttpRequest(p.httpClient, req, token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (p *DefaultRP) trySetStateCookie(w http.ResponseWriter, state string) error {
|
||||
if p.cookieHandler != nil {
|
||||
if err := p.cookieHandler.SetCookie(w, stateParam, state); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DefaultRP) tryReadStateCookie(w http.ResponseWriter, r *http.Request) (state string, err error) {
|
||||
if p.cookieHandler == nil {
|
||||
return r.FormValue(stateParam), nil
|
||||
}
|
||||
state, err = p.cookieHandler.CheckQueryCookie(r, stateParam)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.cookieHandler.DeleteCookie(w, stateParam)
|
||||
return state, nil
|
||||
}
|
363
pkg/rp/default_verifier.go
Normal file
363
pkg/rp/default_verifier.go
Normal file
|
@ -0,0 +1,363 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/utils"
|
||||
)
|
||||
|
||||
//DefaultVerifier implements the `Verifier` interface
|
||||
type DefaultVerifier struct {
|
||||
config *verifierConfig
|
||||
keySet oidc.KeySet
|
||||
}
|
||||
|
||||
//ConfFunc is the type for providing dynamic options to the DefaultVerfifier
|
||||
type ConfFunc func(*verifierConfig)
|
||||
|
||||
//ACRVerifier specifies the function to be used by the `DefaultVerifier` for validating the acr claim
|
||||
type ACRVerifier func(string) error
|
||||
|
||||
//NewDefaultVerifier creates `DefaultVerifier` with the given
|
||||
//issuer, clientID, keyset and possible configOptions
|
||||
func NewDefaultVerifier(issuer, clientID string, keySet oidc.KeySet, confOpts ...ConfFunc) Verifier {
|
||||
conf := &verifierConfig{
|
||||
issuer: issuer,
|
||||
clientID: clientID,
|
||||
iat: &iatConfig{
|
||||
// offset: time.Duration(500 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
for _, opt := range confOpts {
|
||||
if opt != nil {
|
||||
opt(conf)
|
||||
}
|
||||
}
|
||||
return &DefaultVerifier{config: conf, keySet: keySet}
|
||||
}
|
||||
|
||||
//WithIgnoreIssuedAt will turn off iat claim verification
|
||||
func WithIgnoreIssuedAt() func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.iat.ignore = true
|
||||
}
|
||||
}
|
||||
|
||||
//WithIssuedAtOffset mitigates the risk of iat to be in the future
|
||||
//because of clock skews with the ability to add an offset to the current time
|
||||
func WithIssuedAtOffset(offset time.Duration) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.iat.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
//WithIssuedAtMaxAge provides the ability to define the maximum duration between iat and now
|
||||
func WithIssuedAtMaxAge(maxAge time.Duration) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.iat.maxAge = maxAge
|
||||
}
|
||||
}
|
||||
|
||||
//WithNonce TODO: ?
|
||||
func WithNonce(nonce string) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.nonce = nonce
|
||||
}
|
||||
}
|
||||
|
||||
//WithACRVerifier sets the verifier for the acr claim
|
||||
func WithACRVerifier(verifier ACRVerifier) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.acr = verifier
|
||||
}
|
||||
}
|
||||
|
||||
//WithAuthTimeMaxAge provides the ability to define the maximum duration between auth_time and now
|
||||
func WithAuthTimeMaxAge(maxAge time.Duration) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.maxAge = maxAge
|
||||
}
|
||||
}
|
||||
|
||||
//WithSupportedSigningAlgorithms overwrites the default RS256 signing algorithm
|
||||
func WithSupportedSigningAlgorithms(algs ...string) func(*verifierConfig) {
|
||||
return func(conf *verifierConfig) {
|
||||
conf.supportedSignAlgs = algs
|
||||
}
|
||||
}
|
||||
|
||||
type verifierConfig struct {
|
||||
issuer string
|
||||
clientID string
|
||||
nonce string
|
||||
iat *iatConfig
|
||||
acr ACRVerifier
|
||||
maxAge time.Duration
|
||||
supportedSignAlgs []string
|
||||
|
||||
// httpClient *http.Client
|
||||
|
||||
now time.Time
|
||||
}
|
||||
|
||||
type iatConfig struct {
|
||||
ignore bool
|
||||
offset time.Duration
|
||||
maxAge time.Duration
|
||||
}
|
||||
|
||||
//DefaultACRVerifier implements `ACRVerifier` returning an error
|
||||
//if non of the provided values matches the acr claim
|
||||
func DefaultACRVerifier(possibleValues []string) ACRVerifier {
|
||||
return func(acr string) error {
|
||||
if !utils.Contains(possibleValues, acr) {
|
||||
return ErrAcrInvalid(possibleValues, acr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
//Verify implements the `Verify` method of the `Verifier` interface
|
||||
//according to https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
//and https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowTokenValidation
|
||||
func (v *DefaultVerifier) Verify(ctx context.Context, accessToken, idTokenString string) (*oidc.IDTokenClaims, error) {
|
||||
v.config.now = time.Now().UTC()
|
||||
idToken, err := v.VerifyIDToken(ctx, idTokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := v.verifyAccessToken(accessToken, idToken.AccessTokenHash, idToken.Signature); err != nil { //TODO: sig from token
|
||||
return nil, err
|
||||
}
|
||||
return idToken, nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) now() time.Time {
|
||||
if v.config.now.IsZero() {
|
||||
v.config.now = time.Now().UTC().Round(time.Second)
|
||||
}
|
||||
return v.config.now
|
||||
}
|
||||
|
||||
//VerifyIDToken: https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
func (v *DefaultVerifier) VerifyIDToken(ctx context.Context, idTokenString string) (*oidc.IDTokenClaims, error) {
|
||||
//1. if encrypted --> decrypt
|
||||
decrypted, err := v.decryptToken(idTokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, payload, err := v.parseToken(decrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// token, err := jwt.ParseWithClaims(decrypted, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
//2, check issuer (exact match)
|
||||
if err := v.checkIssuer(claims.Issuer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//3. check aud (aud must contain client_id, all aud strings must be allowed)
|
||||
if err = v.checkAudience(claims.Audiences); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = v.checkAuthorizedParty(claims.Audiences, claims.AuthorizedParty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//6. check signature by keys
|
||||
//7. check alg default is rs256
|
||||
//8. check if alg is mac based (hs...) -> audience contains client_id. for validation use utf-8 representation of your client_secret
|
||||
claims.Signature, err = v.checkSignature(ctx, decrypted, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//9. check exp before now
|
||||
if err = v.checkExpiration(claims.Expiration); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//10. check iat duration is optional (can be checked)
|
||||
if err = v.checkIssuedAt(claims.IssuedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//11. check nonce (check if optional possible) id_token.nonce == sentNonce
|
||||
if err = v.checkNonce(claims.Nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//12. if acr requested check acr
|
||||
if err = v.checkAuthorizationContextClassReference(claims.AuthenticationContextClassReference); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//13. if auth_time requested check if auth_time is less than max age
|
||||
if err = v.checkAuthTime(claims.AuthTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) parseToken(tokenString string) (*oidc.IDTokenClaims, []byte, error) {
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, nil, ValidationError("token contains an invalid number of segments") //TODO: err NewValidationError("token contains an invalid number of segments", ValidationErrorMalformed)
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("oidc: malformed jwt payload: %v", err)
|
||||
}
|
||||
idToken := new(oidc.IDTokenClaims)
|
||||
err = json.Unmarshal(payload, idToken)
|
||||
return idToken, payload, err
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) checkIssuer(issuer string) error {
|
||||
if v.config.issuer != issuer {
|
||||
return ErrIssuerInvalid(v.config.issuer, issuer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) checkAudience(audiences []string) error {
|
||||
if !utils.Contains(audiences, v.config.clientID) {
|
||||
return ErrAudienceMissingClientID(v.config.clientID)
|
||||
}
|
||||
|
||||
//TODO: check aud trusted
|
||||
return nil
|
||||
}
|
||||
|
||||
//4. if multiple aud strings --> check if azp
|
||||
//5. if azp --> check azp == client_id
|
||||
func (v *DefaultVerifier) checkAuthorizedParty(audiences []string, authorizedParty string) error {
|
||||
if len(audiences) > 1 {
|
||||
if authorizedParty == "" {
|
||||
return ErrAzpMissing()
|
||||
}
|
||||
}
|
||||
if authorizedParty != "" && authorizedParty != v.config.clientID {
|
||||
return ErrAzpInvalid(authorizedParty, v.config.clientID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) checkSignature(ctx context.Context, idTokenString string, payload []byte) (jose.SignatureAlgorithm, error) {
|
||||
jws, err := jose.ParseSigned(idTokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(jws.Signatures) == 0 {
|
||||
return "", nil //TODO: error
|
||||
}
|
||||
if len(jws.Signatures) > 1 {
|
||||
return "", nil //TODO: error
|
||||
}
|
||||
sig := jws.Signatures[0]
|
||||
supportedSigAlgs := v.config.supportedSignAlgs
|
||||
if len(supportedSigAlgs) == 0 {
|
||||
supportedSigAlgs = []string{"RS256"}
|
||||
}
|
||||
if !utils.Contains(supportedSigAlgs, sig.Header.Algorithm) {
|
||||
return "", fmt.Errorf("oidc: id token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm)
|
||||
}
|
||||
|
||||
signedPayload, err := v.keySet.VerifySignature(ctx, jws)
|
||||
if err != nil {
|
||||
return "", err
|
||||
//TODO:
|
||||
}
|
||||
|
||||
if !bytes.Equal(signedPayload, payload) {
|
||||
return "", ErrSignatureInvalidPayload() //TODO: err
|
||||
}
|
||||
return jose.SignatureAlgorithm(sig.Header.Algorithm), nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) checkExpiration(expiration time.Time) error {
|
||||
expiration = expiration.Round(time.Second)
|
||||
if !v.now().Before(expiration) {
|
||||
return ErrExpInvalid(expiration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) checkIssuedAt(issuedAt time.Time) error {
|
||||
if v.config.iat.ignore {
|
||||
return nil
|
||||
}
|
||||
issuedAt = issuedAt.Round(time.Second)
|
||||
offset := v.now().Add(v.config.iat.offset).Round(time.Second)
|
||||
if issuedAt.After(offset) {
|
||||
return ErrIatInFuture(issuedAt, offset)
|
||||
}
|
||||
if v.config.iat.maxAge == 0 {
|
||||
return nil
|
||||
}
|
||||
maxAge := v.now().Add(-v.config.iat.maxAge).Round(time.Second)
|
||||
if issuedAt.Before(maxAge) {
|
||||
return ErrIatToOld(maxAge, issuedAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (v *DefaultVerifier) checkNonce(nonce string) error {
|
||||
if v.config.nonce == "" {
|
||||
return nil
|
||||
}
|
||||
if v.config.nonce != nonce {
|
||||
return ErrNonceInvalid(v.config.nonce, nonce)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (v *DefaultVerifier) checkAuthorizationContextClassReference(acr string) error {
|
||||
if v.config.acr != nil {
|
||||
return v.config.acr(acr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (v *DefaultVerifier) checkAuthTime(authTime time.Time) error {
|
||||
if v.config.maxAge == 0 {
|
||||
return nil
|
||||
}
|
||||
if authTime.IsZero() {
|
||||
return ErrAuthTimeNotPresent()
|
||||
}
|
||||
authTime = authTime.Round(time.Second)
|
||||
maxAge := v.now().Add(-v.config.maxAge).Round(time.Second)
|
||||
if authTime.Before(maxAge) {
|
||||
return ErrAuthTimeToOld(maxAge, authTime)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) decryptToken(tokenString string) (string, error) {
|
||||
return tokenString, nil //TODO: impl
|
||||
}
|
||||
|
||||
func (v *DefaultVerifier) verifyAccessToken(accessToken, atHash string, sigAlgorithm jose.SignatureAlgorithm) error {
|
||||
if atHash == "" {
|
||||
return nil //TODO: return error
|
||||
}
|
||||
|
||||
actual, err := oidc.ClaimHash(accessToken, sigAlgorithm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual != atHash {
|
||||
return nil //TODO: error
|
||||
}
|
||||
return nil
|
||||
}
|
13
pkg/rp/delegation.go
Normal file
13
pkg/rp/delegation.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"github.com/caos/oidc/pkg/oidc/grants/tokenexchange"
|
||||
)
|
||||
|
||||
//DelegationTokenRequest is an implementation of TokenExchangeRequest
|
||||
//it exchanges a "urn:ietf:params:oauth:token-type:access_token" with an optional
|
||||
//"urn:ietf:params:oauth:token-type:access_token" actor token for a
|
||||
//"urn:ietf:params:oauth:token-type:access_token" delegation token
|
||||
func DelegationTokenRequest(subjectToken string, opts ...tokenexchange.TokenExchangeOption) *tokenexchange.TokenExchangeRequest {
|
||||
return tokenexchange.NewTokenExchangeRequest(subjectToken, tokenexchange.AccessTokenType, opts...)
|
||||
}
|
58
pkg/rp/error.go
Normal file
58
pkg/rp/error.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIssuerInvalid = func(expected, actual string) *validationError {
|
||||
return ValidationError("Issuer does not match. Expected: %s, got: %s", expected, actual)
|
||||
}
|
||||
ErrAudienceMissingClientID = func(clientID string) *validationError {
|
||||
return ValidationError("Audience is not valid. Audience must contain client_id (%s)", clientID)
|
||||
}
|
||||
ErrAzpMissing = func() *validationError {
|
||||
return ValidationError("Authorized Party is not set. If Token is valid for multiple audiences, azp must not be empty")
|
||||
}
|
||||
ErrAzpInvalid = func(azp, clientID string) *validationError {
|
||||
return ValidationError("Authorized Party is not valid. azp (%s) must be equal to client_id (%s)", azp, clientID)
|
||||
}
|
||||
ErrExpInvalid = func(exp time.Time) *validationError {
|
||||
return ValidationError("Token has expired %v", exp)
|
||||
}
|
||||
ErrIatInFuture = func(exp, now time.Time) *validationError {
|
||||
return ValidationError("IssuedAt of token is in the future (%v, now with offset: %v)", exp, now)
|
||||
}
|
||||
ErrIatToOld = func(maxAge, iat time.Time) *validationError {
|
||||
return ValidationError("IssuedAt of token must not be older than %v, but was %v (%v to old)", maxAge, iat, maxAge.Sub(iat))
|
||||
}
|
||||
ErrNonceInvalid = func(expected, actual string) *validationError {
|
||||
return ValidationError("nonce does not match. Expected: %s, got: %s", expected, actual)
|
||||
}
|
||||
ErrAcrInvalid = func(expected []string, actual string) *validationError {
|
||||
return ValidationError("acr is invalid. Expected one of: %v, got: %s", expected, actual)
|
||||
}
|
||||
|
||||
ErrAuthTimeNotPresent = func() *validationError {
|
||||
return ValidationError("claim `auth_time` of token is missing")
|
||||
}
|
||||
ErrAuthTimeToOld = func(maxAge, authTime time.Time) *validationError {
|
||||
return ValidationError("Auth Time of token must not be older than %v, but was %v (%v to old)", maxAge, authTime, maxAge.Sub(authTime))
|
||||
}
|
||||
ErrSignatureInvalidPayload = func() *validationError {
|
||||
return ValidationError("Signature does not match Payload")
|
||||
}
|
||||
)
|
||||
|
||||
func ValidationError(message string, args ...interface{}) *validationError {
|
||||
return &validationError{fmt.Sprintf(message, args...)} //TODO: impl
|
||||
}
|
||||
|
||||
type validationError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (v *validationError) Error() string {
|
||||
return v.message
|
||||
}
|
166
pkg/rp/jwks.go
Normal file
166
pkg/rp/jwks.go
Normal file
|
@ -0,0 +1,166 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/caos/oidc/pkg/utils"
|
||||
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
func NewRemoteKeySet(client *http.Client, jwksURL string) oidc.KeySet {
|
||||
return &remoteKeySet{httpClient: client, jwksURL: jwksURL}
|
||||
}
|
||||
|
||||
type remoteKeySet struct {
|
||||
jwksURL string
|
||||
httpClient *http.Client
|
||||
|
||||
// guard all other fields
|
||||
mu sync.Mutex
|
||||
|
||||
// inflight suppresses parallel execution of updateKeys and allows
|
||||
// multiple goroutines to wait for its result.
|
||||
inflight *inflight
|
||||
|
||||
// A set of cached keys and their expiry.
|
||||
cachedKeys []jose.JSONWebKey
|
||||
}
|
||||
|
||||
// inflight is used to wait on some in-flight request from multiple goroutines.
|
||||
type inflight struct {
|
||||
doneCh chan struct{}
|
||||
|
||||
keys []jose.JSONWebKey
|
||||
err error
|
||||
}
|
||||
|
||||
func newInflight() *inflight {
|
||||
return &inflight{doneCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
// wait returns a channel that multiple goroutines can receive on. Once it returns
|
||||
// a value, the inflight request is done and result() can be inspected.
|
||||
func (i *inflight) wait() <-chan struct{} {
|
||||
return i.doneCh
|
||||
}
|
||||
|
||||
// done can only be called by a single goroutine. It records the result of the
|
||||
// inflight request and signals other goroutines that the result is safe to
|
||||
// inspect.
|
||||
func (i *inflight) done(keys []jose.JSONWebKey, err error) {
|
||||
i.keys = keys
|
||||
i.err = err
|
||||
close(i.doneCh)
|
||||
}
|
||||
|
||||
// result cannot be called until the wait() channel has returned a value.
|
||||
func (i *inflight) result() ([]jose.JSONWebKey, error) {
|
||||
return i.keys, i.err
|
||||
}
|
||||
|
||||
func (r *remoteKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
|
||||
// We don't support JWTs signed with multiple signatures.
|
||||
keyID := ""
|
||||
for _, sig := range jws.Signatures {
|
||||
keyID = sig.Header.KeyID
|
||||
break
|
||||
}
|
||||
|
||||
keys := r.keysFromCache()
|
||||
payload, err, ok := checkKey(keyID, keys, jws)
|
||||
if ok {
|
||||
return payload, err
|
||||
}
|
||||
|
||||
keys, err = r.keysFromRemote(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching keys %v", err)
|
||||
}
|
||||
|
||||
payload, err, ok = checkKey(keyID, keys, jws)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid kid")
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func (r *remoteKeySet) keysFromCache() (keys []jose.JSONWebKey) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.cachedKeys
|
||||
}
|
||||
|
||||
// keysFromRemote syncs the key set from the remote set, records the values in the
|
||||
// cache, and returns the key set.
|
||||
func (r *remoteKeySet) keysFromRemote(ctx context.Context) ([]jose.JSONWebKey, error) {
|
||||
// Need to lock to inspect the inflight request field.
|
||||
r.mu.Lock()
|
||||
// If there's not a current inflight request, create one.
|
||||
if r.inflight == nil {
|
||||
r.inflight = newInflight()
|
||||
|
||||
// This goroutine has exclusive ownership over the current inflight
|
||||
// request. It releases the resource by nil'ing the inflight field
|
||||
// once the goroutine is done.
|
||||
go r.updateKeys(ctx)
|
||||
}
|
||||
inflight := r.inflight
|
||||
r.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-inflight.wait():
|
||||
return inflight.result()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *remoteKeySet) updateKeys(ctx context.Context) {
|
||||
// Sync keys and finish inflight when that's done.
|
||||
keys, err := r.fetchRemoteKeys(ctx)
|
||||
|
||||
r.inflight.done(keys, err)
|
||||
|
||||
// Lock to update the keys and indicate that there is no longer an
|
||||
// inflight request.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
r.cachedKeys = keys
|
||||
}
|
||||
|
||||
// Free inflight so a different request can run.
|
||||
r.inflight = nil
|
||||
}
|
||||
|
||||
func (r *remoteKeySet) fetchRemoteKeys(ctx context.Context) ([]jose.JSONWebKey, error) {
|
||||
req, err := http.NewRequest("GET", r.jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: can't create request: %v", err)
|
||||
}
|
||||
|
||||
keySet := new(jose.JSONWebKeySet)
|
||||
if err = utils.HttpRequest(r.httpClient, req, keySet); err != nil {
|
||||
return nil, fmt.Errorf("oidc: failed to get keys: %v", err)
|
||||
}
|
||||
|
||||
return keySet.Keys, nil
|
||||
}
|
||||
|
||||
func checkKey(keyID string, keys []jose.JSONWebKey, jws *jose.JSONWebSignature) ([]byte, error, bool) {
|
||||
for _, key := range keys {
|
||||
if keyID == "" || key.KeyID == keyID {
|
||||
payload, err := jws.Verify(&key)
|
||||
return payload, err, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
105
pkg/rp/relaying_party.go
Normal file
105
pkg/rp/relaying_party.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
//RelayingParty declares the minimal interface for oidc clients
|
||||
type RelayingParty interface {
|
||||
|
||||
//AuthURL returns the authorization endpoint with a given state
|
||||
AuthURL(state string, opts ...AuthURLOpt) string
|
||||
|
||||
//AuthURLHandler should implement the AuthURL func as http.HandlerFunc
|
||||
//(redirecting to the auth endpoint)
|
||||
AuthURLHandler(state string) http.HandlerFunc
|
||||
|
||||
//CodeExchange implements the OIDC Token Request (oauth2 Authorization Code Grant)
|
||||
//returning an `Access Token` and `ID Token Claims`
|
||||
CodeExchange(ctx context.Context, code string, opts ...CodeExchangeOpt) (*oidc.Tokens, error)
|
||||
|
||||
//CodeExchangeHandler extends the CodeExchange func,
|
||||
//calling the provided callback func on success with additional returned `state`
|
||||
CodeExchangeHandler(callback func(http.ResponseWriter, *http.Request, *oidc.Tokens, string)) http.HandlerFunc
|
||||
|
||||
//ClientCredentials implements the oauth2 Client Credentials Grant
|
||||
//requesting an `Access Token` for the client itself, without user context
|
||||
ClientCredentials(ctx context.Context, scopes ...string) (*oauth2.Token, error)
|
||||
|
||||
//Introspects calls the Introspect Endpoint
|
||||
//for validating an (access) token
|
||||
// Introspect(ctx context.Context, token string) (TokenIntrospectResponse, error)
|
||||
|
||||
//Userinfo implements the OIDC Userinfo call
|
||||
//returning the info of the user for the requested scopes of an access token
|
||||
Userinfo()
|
||||
}
|
||||
|
||||
//PasswortGrantRP extends the `RelayingParty` interface with the oauth2 `Password Grant`
|
||||
//
|
||||
//This interface is separated from the standard `RelayingParty` interface as the `password grant`
|
||||
//is part of the oauth2 and therefore OIDC specification, but should only be used when there's no
|
||||
//other possibility, so IMHO never ever. Ever.
|
||||
type PasswortGrantRP interface {
|
||||
RelayingParty
|
||||
|
||||
//PasswordGrant implements the oauth2 `Password Grant`,
|
||||
//requesting an access token with the users `username` and `password`
|
||||
PasswordGrant(context.Context, string, string) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
CallbackURL string
|
||||
Issuer string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
type OptionFunc func(RelayingParty)
|
||||
|
||||
type Endpoints struct {
|
||||
oauth2.Endpoint
|
||||
IntrospectURL string
|
||||
UserinfoURL string
|
||||
JKWsURL string
|
||||
}
|
||||
|
||||
func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints {
|
||||
return Endpoints{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: discoveryConfig.AuthorizationEndpoint,
|
||||
AuthStyle: oauth2.AuthStyleAutoDetect,
|
||||
TokenURL: discoveryConfig.TokenEndpoint,
|
||||
},
|
||||
IntrospectURL: discoveryConfig.IntrospectionEndpoint,
|
||||
UserinfoURL: discoveryConfig.UserinfoEndpoint,
|
||||
JKWsURL: discoveryConfig.JwksURI,
|
||||
}
|
||||
}
|
||||
|
||||
type AuthURLOpt func() []oauth2.AuthCodeOption
|
||||
|
||||
//WithCodeChallenge sets the `code_challenge` params in the auth request
|
||||
func WithCodeChallenge(codeChallenge string) AuthURLOpt {
|
||||
return func() []oauth2.AuthCodeOption {
|
||||
return []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CodeExchangeOpt func() []oauth2.AuthCodeOption
|
||||
|
||||
//WithCodeVerifier sets the `code_verifier` param in the token request
|
||||
func WithCodeVerifier(codeVerifier string) CodeExchangeOpt {
|
||||
return func() []oauth2.AuthCodeOption {
|
||||
return []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("code_verifier", codeVerifier)}
|
||||
}
|
||||
}
|
27
pkg/rp/tockenexchange.go
Normal file
27
pkg/rp/tockenexchange.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc/grants/tokenexchange"
|
||||
)
|
||||
|
||||
//TokenExchangeRP extends the `RelayingParty` interface for the *draft* oauth2 `Token Exchange`
|
||||
type TokenExchangeRP interface {
|
||||
RelayingParty
|
||||
|
||||
//TokenExchange implement the `Token Echange Grant` exchanging some token for an other
|
||||
TokenExchange(context.Context, *tokenexchange.TokenExchangeRequest) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
//DelegationTokenExchangeRP extends the `TokenExchangeRP` interface
|
||||
//for the specific `delegation token` request
|
||||
type DelegationTokenExchangeRP interface {
|
||||
TokenExchangeRP
|
||||
|
||||
//DelegationTokenExchange implement the `Token Exchange Grant`
|
||||
//providing an access token in request for a `delegation` token for a given resource / audience
|
||||
DelegationTokenExchange(context.Context, string, ...tokenexchange.TokenExchangeOption) (*oauth2.Token, error)
|
||||
}
|
15
pkg/rp/verifier.go
Normal file
15
pkg/rp/verifier.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
//Verifier implement the Token Response Validation as defined in OIDC specification
|
||||
//https://openid.net/specs/openid-connect-core-1_0.html#TokenResponseValidation
|
||||
type Verifier interface {
|
||||
|
||||
//Verify checks the access_token and id_token and returns the `id token claims`
|
||||
Verify(ctx context.Context, accessToken, idTokenString string) (*oidc.IDTokenClaims, error)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue