zitadel-oidc/pkg/rp/relaying_party.go
2020-09-15 07:25:44 +02:00

427 lines
13 KiB
Go

package rp
import (
"context"
"net/http"
"strings"
"github.com/caos/oidc/pkg/oidc"
"github.com/caos/oidc/pkg/oidc/grants"
"github.com/caos/oidc/pkg/utils"
"golang.org/x/oauth2"
)
//RelayingParty declares the minimal interface for oidc clients
type RelayingParty interface {
//OAuthConfig returns the oauth2 Config
OAuthConfig() *oauth2.Config
//IsPKCE returns if authorization is done using `Authorization Code Flow with Proof Key for Code Exchange (PKCE)`
IsPKCE() bool
//CookieHandler returns a http cookie handler used for various state transfer cookies
CookieHandler() *utils.CookieHandler
//Client return a standard http client where the token can be used
Client(ctx context.Context, token *oauth2.Token) *http.Client
/*
//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()
*/
HttpClient() *http.Client
IsOAuth2Only() bool
IDTokenVerifier() IDTokenVerifier
ErrorHandler() func(http.ResponseWriter, *http.Request, string, string, string)
}
//
////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)
//}
var (
DefaultErrorHandler = func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) {
http.Error(w, errorType+": "+errorDesc, http.StatusInternalServerError)
}
)
type relayingParty struct {
endpoints Endpoints
oauthConfig *oauth2.Config
config *Configuration
pkce bool
httpClient *http.Client
cookieHandler *utils.CookieHandler
errorHandler func(http.ResponseWriter, *http.Request, string, string, string)
idTokenVerifier IDTokenVerifier
verifierOpts []ConfFunc
oauth2Only bool
}
func (rp *relayingParty) OAuthConfig() *oauth2.Config {
return rp.oauthConfig
}
func (rp *relayingParty) IsPKCE() bool {
return rp.pkce
}
func (rp *relayingParty) CookieHandler() *utils.CookieHandler {
return rp.cookieHandler
}
func (rp *relayingParty) HttpClient() *http.Client {
return rp.httpClient
}
func (rp *relayingParty) IsOAuth2Only() bool {
return rp.oauth2Only
}
func (rp *relayingParty) IDTokenVerifier() IDTokenVerifier {
return rp.idTokenVerifier
}
func (rp *relayingParty) Client(ctx context.Context, token *oauth2.Token) *http.Client {
panic("implement me")
}
func (rp *relayingParty) ErrorHandler() func(http.ResponseWriter, *http.Request, string, string, string) {
return rp.errorHandler
}
//NewRelayingParty creates a DelegationTokenExchangeRP with the given
//Config and possible configOptions
//it will run discovery on the provided issuer if AuthURL and TokenURL are not set
//if no verifier is provided using the options the `DefaultVerifier` is set
func NewRelayingParty(config *Configuration, options ...Option) (RelayingParty, error) {
isOpenID := isOpenID(config.Scopes)
rp := &relayingParty{
config: config,
httpClient: utils.DefaultHTTPClient,
oauth2Only: !isOpenID,
}
for _, optFunc := range options {
optFunc(rp)
}
rp.oauthConfig = config.Config
if config.Endpoint.AuthURL != "" && config.Endpoint.TokenURL != "" {
rp.oauthConfig = config.Config
} else {
endpoints, err := Discover(config.Issuer, rp.httpClient)
if err != nil {
return nil, err
}
rp.oauthConfig.Endpoint = endpoints.Endpoint
rp.endpoints = endpoints
}
if rp.errorHandler == nil {
rp.errorHandler = DefaultErrorHandler
}
if isOpenID && rp.idTokenVerifier == nil {
rp.idTokenVerifier = NewIDTokenVerifier(config.Issuer, config.ClientID, NewRemoteKeySet(rp.httpClient, rp.endpoints.JKWsURL))
}
return rp, nil
}
//DefaultRPOpts is the type for providing dynamic options to the DefaultRP
type Option func(*relayingParty)
//WithCookieHandler set a `CookieHandler` for securing the various redirects
func WithCookieHandler(cookieHandler *utils.CookieHandler) Option {
return func(rp *relayingParty) {
rp.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) Option {
return func(rp *relayingParty) {
rp.pkce = true
rp.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) Option {
return func(rp *relayingParty) {
rp.httpClient = client
}
}
func WithVerifierOpts(opts ...ConfFunc) Option {
return func(rp *relayingParty) {
rp.verifierOpts = opts
}
}
//Discover calls the discovery endpoint of the provided issuer and returns the found endpoints
func Discover(issuer string, httpClient *http.Client) (Endpoints, error) {
wellKnown := strings.TrimSuffix(issuer, "/") + oidc.DiscoveryEndpoint
req, err := http.NewRequest("GET", wellKnown, nil)
if err != nil {
return Endpoints{}, err
}
discoveryConfig := new(oidc.DiscoveryConfiguration)
err = utils.HttpRequest(httpClient, req, &discoveryConfig)
if err != nil {
return Endpoints{}, err
}
return GetEndpoints(discoveryConfig), nil
}
//AuthURL returns the auth request url
//(wrapping the oauth2 `AuthCodeURL`)
func AuthURL(state string, rp RelayingParty, opts ...AuthURLOpt) string {
authOpts := make([]oauth2.AuthCodeOption, 0)
for _, opt := range opts {
authOpts = append(authOpts, opt()...)
}
return rp.OAuthConfig().AuthCodeURL(state, authOpts...)
}
//AuthURLHandler extends the `AuthURL` method with a http redirect handler
//including handling setting cookie for secure `state` transfer
func AuthURLHandler(state string, rp RelayingParty) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
opts := make([]AuthURLOpt, 0)
if err := trySetStateCookie(w, state, rp); err != nil {
http.Error(w, "failed to create state cookie: "+err.Error(), http.StatusUnauthorized)
return
}
if rp.IsPKCE() {
codeChallenge, err := GenerateAndStoreCodeChallenge(w, rp)
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, AuthURL(state, rp, opts...), http.StatusFound)
}
}
func GenerateAndStoreCodeChallenge(w http.ResponseWriter, rp RelayingParty) (string, error) {
var codeVerifier string
codeVerifier = "s"
if err := rp.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 CodeExchange(ctx context.Context, code string, rp RelayingParty, opts ...CodeExchangeOpt) (tokens *oidc.Tokens, err error) {
ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient())
codeOpts := make([]oauth2.AuthCodeOption, 0)
for _, opt := range opts {
codeOpts = append(codeOpts, opt()...)
}
token, err := rp.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 := new(oidc.IDTokenClaims)
if !rp.IsOAuth2Only() {
idToken, err = VerifyTokens(ctx, token.AccessToken, idTokenString, rp.IDTokenVerifier())
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 CodeExchangeHandler(callback func(http.ResponseWriter, *http.Request, *oidc.Tokens, string), rp RelayingParty) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state, err := tryReadStateCookie(w, r, rp)
if err != nil {
http.Error(w, "failed to get state: "+err.Error(), http.StatusUnauthorized)
return
}
params := r.URL.Query()
if params.Get("error") != "" {
rp.ErrorHandler()(w, r, params.Get("error"), params.Get("error_description"), state)
return
}
codeOpts := make([]CodeExchangeOpt, 0)
if rp.IsPKCE() {
codeVerifier, err := rp.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 := CodeExchange(r.Context(), params.Get("code"), rp, codeOpts...)
if err != nil {
http.Error(w, "failed to exchange token: "+err.Error(), http.StatusUnauthorized)
return
}
callback(w, r, tokens, state)
}
}
//ClientCredentials is the `RelayingParty` interface implementation
//handling the oauth2 client credentials grant
func ClientCredentials(ctx context.Context, rp RelayingParty, scopes ...string) (newToken *oauth2.Token, err error) {
return CallTokenEndpoint(grants.ClientCredentialsGrantBasic(scopes...), rp)
}
func CallTokenEndpoint(request interface{}, rp RelayingParty) (newToken *oauth2.Token, err error) {
config := rp.OAuthConfig()
req, err := utils.FormRequest(rp.OAuthConfig().Endpoint.TokenURL, request, config.ClientID, config.ClientSecret, config.Endpoint.AuthStyle != oauth2.AuthStyleInParams)
if err != nil {
return nil, err
}
token := new(oauth2.Token)
if err := utils.HttpRequest(rp.HttpClient(), req, token); err != nil {
return nil, err
}
return token, nil
}
func trySetStateCookie(w http.ResponseWriter, state string, rp RelayingParty) error {
if rp.CookieHandler() != nil {
if err := rp.CookieHandler().SetCookie(w, stateParam, state); err != nil {
return err
}
}
return nil
}
func tryReadStateCookie(w http.ResponseWriter, r *http.Request, rp RelayingParty) (state string, err error) {
if rp.CookieHandler() == nil {
return r.FormValue(stateParam), nil
}
state, err = rp.CookieHandler().CheckQueryCookie(r, stateParam)
if err != nil {
return "", err
}
rp.CookieHandler().DeleteCookie(w, stateParam)
return state, nil
}
type Configuration struct {
Issuer string
*oauth2.Config
}
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)}
}
}
func isOpenID(scopes []string) bool {
for _, scope := range scopes {
if scope == oidc.ScopeOpenID {
return true
}
}
return false
}
//deprecated: use Configuration instead
type Config struct {
ClientID string
ClientSecret string
CallbackURL string
Issuer string
Scopes []string
Endpoints oauth2.Endpoint
}