From a0cf7602b8482f0055f83a328f0b01c10883f0d1 Mon Sep 17 00:00:00 2001 From: Simon Murray Date: Mon, 27 Jan 2025 12:21:19 +0000 Subject: [PATCH] Refactor OIDC/Oauth2 Handling (#161) Separate oauth2 from OIDC, which is a layer on top of the former to provide a common set of interfaces that can then be consumed by the providers to do whatever special handling they need. --- charts/identity/Chart.yaml | 4 +- .../identity/templates/identity/ingress.yaml | 2 + pkg/oauth2/common/authorization_code.go | 52 ++ pkg/oauth2/oauth2.go | 615 +++++------------- pkg/oauth2/oidc/authorization_code.go | 109 ++++ pkg/oauth2/{oidc.go => oidc/types.go} | 28 +- pkg/oauth2/providers/factory.go | 7 +- pkg/oauth2/providers/github/provider.go | 180 +++++ pkg/oauth2/providers/google/provider.go | 32 +- pkg/oauth2/providers/interfaces.go | 31 +- pkg/oauth2/providers/microsoft/provider.go | 55 ++ pkg/oauth2/providers/null.go | 22 +- pkg/oauth2/providers/types/types.go | 23 - pkg/oauth2/types/types.go | 61 ++ 14 files changed, 677 insertions(+), 544 deletions(-) create mode 100644 pkg/oauth2/common/authorization_code.go create mode 100644 pkg/oauth2/oidc/authorization_code.go rename pkg/oauth2/{oidc.go => oidc/types.go} (81%) create mode 100644 pkg/oauth2/providers/github/provider.go create mode 100644 pkg/oauth2/providers/microsoft/provider.go delete mode 100644 pkg/oauth2/providers/types/types.go create mode 100644 pkg/oauth2/types/types.go diff --git a/charts/identity/Chart.yaml b/charts/identity/Chart.yaml index 28ba65a..b0856f6 100644 --- a/charts/identity/Chart.yaml +++ b/charts/identity/Chart.yaml @@ -4,8 +4,8 @@ description: A Helm chart for deploying Unikorn's IdP type: application -version: v0.2.52-rc4 -appVersion: v0.2.52-rc4 +version: v0.2.52-rc5 +appVersion: v0.2.52-rc5 icon: https://raw.githubusercontent.com/unikorn-cloud/assets/main/images/logos/dark-on-light/icon.png diff --git a/charts/identity/templates/identity/ingress.yaml b/charts/identity/templates/identity/ingress.yaml index 41ca6c4..1d72231 100644 --- a/charts/identity/templates/identity/ingress.yaml +++ b/charts/identity/templates/identity/ingress.yaml @@ -7,6 +7,8 @@ metadata: annotations: {{- include "unikorn.ingress.clusterIssuer.annotations" . | nindent 4 }} {{- include "unikorn.ingress.mtls.annotations" . | nindent 4 }} + # Handles large token sizes. + nginx.ingress.kubernetes.io/proxy-buffer-size: 16k {{- if (include "unikorn.ingress.externalDNS" .) }} external-dns.alpha.kubernetes.io/hostname: {{ include "unikorn.identity.host" . }} {{- end }} diff --git a/pkg/oauth2/common/authorization_code.go b/pkg/oauth2/common/authorization_code.go new file mode 100644 index 0000000..e6fece5 --- /dev/null +++ b/pkg/oauth2/common/authorization_code.go @@ -0,0 +1,52 @@ +/* +Copyright 2024-2025 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "context" + + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/types" +) + +// Config returns an oauth2 configuration. +func Config(parameters *types.ConfigParameters, scopes []string) *oauth2.Config { + config := &oauth2.Config{ + ClientID: parameters.Provider.Spec.ClientID, + ClientSecret: parameters.Provider.Spec.ClientSecret, + RedirectURL: "https://" + parameters.Host + "/oidc/callback", + Scopes: scopes, + } + + if parameters.Provider.Spec.AuthorizationURI != nil && parameters.Provider.Spec.TokenURI != nil { + config.Endpoint.AuthURL = *parameters.Provider.Spec.AuthorizationURI + config.Endpoint.TokenURL = *parameters.Provider.Spec.TokenURI + } + + return config +} + +// Authorization gets the oauth2 authorization URL. +func Authorization(config *oauth2.Config, parameters *types.AuthorizationParamters, requestParameters []oauth2.AuthCodeOption) string { + return config.AuthCodeURL(parameters.State, requestParameters...) +} + +// CodeExchange exchanges a code with an oauth2 server. +func CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, error) { + return Config(¶meters.ConfigParameters, nil).Exchange(ctx, parameters.Code) +} diff --git a/pkg/oauth2/oauth2.go b/pkg/oauth2/oauth2.go index 9d97710..7320fee 100644 --- a/pkg/oauth2/oauth2.go +++ b/pkg/oauth2/oauth2.go @@ -23,28 +23,26 @@ import ( "crypto/sha256" "crypto/sha512" "encoding/base64" - "encoding/json" goerrors "errors" "fmt" - "io" "net/http" "net/url" "slices" "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v3/jwt" "github.com/spf13/pflag" "golang.org/x/oauth2" coreopenapi "github.com/unikorn-cloud/core/pkg/openapi" "github.com/unikorn-cloud/core/pkg/server/errors" - "github.com/unikorn-cloud/core/pkg/util/retry" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" "github.com/unikorn-cloud/identity/pkg/html" "github.com/unikorn-cloud/identity/pkg/jose" + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" "github.com/unikorn-cloud/identity/pkg/oauth2/providers" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" "github.com/unikorn-cloud/identity/pkg/util" @@ -59,7 +57,6 @@ var ( ErrUnsupportedProviderType = goerrors.New("unhandled provider type") ErrReference = goerrors.New("resource reference error") ErrUserNotDomainMapped = goerrors.New("user is not domain mapped to an organization") - ErrEmailLookup = goerrors.New("failed to lookup email") ) type Options struct { @@ -145,7 +142,7 @@ type State struct { Nonce string `json:"n"` // Code verfier is required to prove our identity when // exchanging the code with the token endpoint. - CodeVerfier string `json:"cv"` + CodeVerifier string `json:"cv"` // OAuth2Provider is the name of the provider configuration in // use, this will reference the issuer and allow discovery. OAuth2Provider string `json:"oap"` @@ -186,11 +183,7 @@ type Code struct { // ClientNonce is injected into a OIDC id_token. ClientNonce string `json:"cno,omitempty"` // IDToken is the full set of claims returned by the provider. - IDToken *IDToken `json:"idt"` - // AccessToken is the user's access token. - AccessToken string `json:"at"` - // RefreshToken is the users's refresh token. - RefreshToken string `json:"rt"` + IDToken *oidc.IDToken `json:"idt"` // AccessTokenExpiry tells us how long the token will last for. AccessTokenExpiry time.Time `json:"ate"` // OAuth2Provider is the name of the provider configuration in @@ -240,6 +233,111 @@ func (a *Authenticator) lookupClient(ctx context.Context, id string) (*unikornv1 return cli, nil } +// lookupOrganization maps from an email address to an organization, this handles +// corporate mandates that say your entire domain have to use a single sign on +// provider across the entire enterprise. +func (a *Authenticator) lookupOrganization(ctx context.Context, email string) (*unikornv1.Organization, error) { + // TODO: error checking. + parts := strings.Split(email, "@") + + // TODO: error checking. + domain := parts[1] + + var organizations unikornv1.OrganizationList + + if err := a.client.List(ctx, &organizations, &client.ListOptions{Namespace: a.namespace}); err != nil { + return nil, err + } + + for i := range organizations.Items { + if organizations.Items[i].Spec.Domain == nil { + continue + } + + if *organizations.Items[i].Spec.Domain == domain { + return &organizations.Items[i], nil + } + } + + return nil, ErrUserNotDomainMapped +} + +// getProviders lists all identity providers. +func (a *Authenticator) getProviders(ctx context.Context) (*unikornv1.OAuth2ProviderList, error) { + resources := &unikornv1.OAuth2ProviderList{} + + if err := a.client.List(ctx, resources, &client.ListOptions{Namespace: a.namespace}); err != nil { + return nil, err + } + + return resources, nil +} + +func (a *Authenticator) getProviderTypes(ctx context.Context) ([]string, error) { + resources, err := a.getProviders(ctx) + if err != nil { + return nil, err + } + + result := make([]string, 0, len(resources.Items)) + + for _, resource := range resources.Items { + if resource.Spec.Type != nil && *resource.Spec.Type != "" { + result = append(result, string(*resource.Spec.Type)) + } + } + + return result, nil +} + +// lookupProviderByType finds the provider configuration by the type chosen by the user. +func (a *Authenticator) lookupProviderByType(ctx context.Context, t unikornv1.IdentityProviderType) (*unikornv1.OAuth2Provider, error) { + resources, err := a.getProviders(ctx) + if err != nil { + return nil, err + } + + for i := range resources.Items { + if resources.Items[i].Spec.Type != nil && *resources.Items[i].Spec.Type == t { + return &resources.Items[i], nil + } + } + + return nil, ErrUnsupportedProviderType +} + +// lookupProviderByID finds the provider based on ID. +func (a *Authenticator) lookupProviderByID(ctx context.Context, id string, organization *unikornv1.Organization) (*unikornv1.OAuth2Provider, error) { + providers := &unikornv1.OAuth2ProviderList{} + + if err := a.client.List(ctx, providers); err != nil { + return nil, err + } + + find := func(provider unikornv1.OAuth2Provider) bool { + return provider.Name == id + } + + index := slices.IndexFunc(providers.Items, find) + if index < 0 { + return nil, fmt.Errorf("%w: requested provider does not exist", ErrReference) + } + + provider := &providers.Items[index] + + // If the provider is neither global, nor scoped to the provided organization, reject. + // NOTE: when called by the authorization endpoint and an email is provided, that email + // maps to an organization, and the provider must be in that organization to avoid + // jailbreaking. In later provider authorization and token exchanges we can trust the + // ID as it's already been checked and it has been cryptographically protected against + // tamering. + if provider.Namespace != a.namespace && (organization == nil || provider.Namespace != organization.Status.Namespace) { + return nil, fmt.Errorf("%w: requested provider not allowed", ErrReference) + } + + return provider, nil +} + // OAuth2AuthorizationValidateNonRedirecting checks authorization request parameters // are valid that directly control the ability to redirect, and returns some helpful // debug in HTML. @@ -303,51 +401,6 @@ func (a *Authenticator) authorizationValidateRedirecting(w http.ResponseWriter, return false } -// useOauth2 is a quick hack, we should probably encode this as part of the CRD? -func useOauth2(provider *unikornv1.OAuth2Provider) bool { - if provider.Spec.Type != nil && *provider.Spec.Type == unikornv1.GitHub { - return true - } - - return false -} - -// oidcConfig returns a oauth2 configuration for the OIDC backend. -func oidcConfig(ctx context.Context, host string, provider *unikornv1.OAuth2Provider, scopes []string) (*oidc.Provider, *oauth2.Config, error) { - // Do service disocvery. - oidcProvider, err := newOIDCProvider(ctx, provider) - if err != nil { - return nil, nil, err - } - - endpoint := oidcProvider.Endpoint() - - config := oauth2Config(host, provider, &endpoint, slices.Concat([]string{oidc.ScopeOpenID, "profile", "email"}, scopes)) - - return oidcProvider, config, nil -} - -// oauth2Config returns an oauth2 configuration for the oauth2 only backend. -func oauth2Config(host string, provider *unikornv1.OAuth2Provider, endpoint *oauth2.Endpoint, scopes []string) *oauth2.Config { - config := &oauth2.Config{ - ClientID: provider.Spec.ClientID, - ClientSecret: provider.Spec.ClientSecret, - RedirectURL: "https://" + host + "/oidc/callback", - Scopes: scopes, - } - - if endpoint != nil { - config.Endpoint = *endpoint - } - - if provider.Spec.AuthorizationURI != nil && provider.Spec.TokenURI != nil { - config.Endpoint.AuthURL = *provider.Spec.AuthorizationURI - config.Endpoint.TokenURL = *provider.Spec.TokenURI - } - - return config -} - // encodeCodeChallengeS256 performs code verifier to code challenge translation // for the SHA256 method. func encodeCodeChallengeS256(codeVerifier string) string { @@ -358,7 +411,7 @@ func encodeCodeChallengeS256(codeVerifier string) string { // randomString creates size bytes of high entropy randomness and base64 URL // encodes it into a string. Bear in mind base64 expands the size by 33%, so for example -// an oauth2 code verifier needs to be at least 43 bytes, so youd nee'd a size of 32, +// an oauth2 code verifier needs to be at least 43 bytes, so you'd need a size of 32, // 32 * 1.33 = 42.66. func randomString(size int) (string, error) { buf := make([]byte, size) @@ -370,6 +423,7 @@ func randomString(size int) (string, error) { return base64.RawURLEncoding.EncodeToString(buf), nil } +// LoginStateClaims are used to encrypt information across the login dialog. type LoginStateClaims struct { Query string `json:"query"` } @@ -414,7 +468,6 @@ func (a *Authenticator) Authorization(w http.ResponseWriter, r *http.Request) { loginQuery.Set("state", state) loginQuery.Set("callback", "https://"+r.Host+"/oauth2/v2/login") - // TODO: this needs to be driven by the available oauth2providers loginQuery.Set("providers", strings.Join(supportedTypes, " ")) // Redirect to an external login handler, if you have chosen to. @@ -439,125 +492,9 @@ func (a *Authenticator) Authorization(w http.ResponseWriter, r *http.Request) { } } -// lookupOrganization maps from an email address to an organization, this handles -// corporate mandates that say your entire domain have to use a single sign on -// provider across the entire enterprise. -func (a *Authenticator) lookupOrganization(ctx context.Context, email string) (*unikornv1.Organization, error) { - // TODO: error checking. - parts := strings.Split(email, "@") - - // TODO: error checking. - domain := parts[1] - - var organizations unikornv1.OrganizationList - - if err := a.client.List(ctx, &organizations, &client.ListOptions{Namespace: a.namespace}); err != nil { - return nil, err - } - - for i := range organizations.Items { - if organizations.Items[i].Spec.Domain == nil { - continue - } - - if *organizations.Items[i].Spec.Domain == domain { - return &organizations.Items[i], nil - } - } - - return nil, ErrUserNotDomainMapped -} - -// getProviders lists all identity providers. -func (a *Authenticator) getProviders(ctx context.Context) (*unikornv1.OAuth2ProviderList, error) { - resources := &unikornv1.OAuth2ProviderList{} - - if err := a.client.List(ctx, resources, &client.ListOptions{Namespace: a.namespace}); err != nil { - return nil, err - } - - return resources, nil -} - -func (a *Authenticator) getProviderTypes(ctx context.Context) ([]string, error) { - resources, err := a.getProviders(ctx) - if err != nil { - return nil, err - } - - result := make([]string, 0, len(resources.Items)) - - for _, resource := range resources.Items { - if resource.Spec.Type != nil && *resource.Spec.Type != "" { - result = append(result, string(*resource.Spec.Type)) - } - } - - return result, nil -} - -// lookupProviderByType finds the provider configuration by the type chosen by the user. -func (a *Authenticator) lookupProviderByType(ctx context.Context, t unikornv1.IdentityProviderType) (*unikornv1.OAuth2Provider, error) { - resources, err := a.getProviders(ctx) - if err != nil { - return nil, err - } - - for i := range resources.Items { - if resources.Items[i].Spec.Type != nil && *resources.Items[i].Spec.Type == t { - return &resources.Items[i], nil - } - } - - return nil, ErrUnsupportedProviderType -} - -// lookupProviderByID finds the provider based on ID. -func (a *Authenticator) lookupProviderByID(ctx context.Context, id string, organization *unikornv1.Organization) (*unikornv1.OAuth2Provider, error) { - providers := &unikornv1.OAuth2ProviderList{} - - if err := a.client.List(ctx, providers); err != nil { - return nil, err - } - - find := func(provider unikornv1.OAuth2Provider) bool { - return provider.Name == id - } - - index := slices.IndexFunc(providers.Items, find) - if index < 0 { - return nil, fmt.Errorf("%w: requested provider does not exist", ErrReference) - } - - provider := &providers.Items[index] - - // If the provider is neither global, nor scoped to the provided organization, reject. - // NOTE: when called by the authorization endpoint and an email is provided, that email - // maps to an organization, and the provider must be in that organization to avoid - // jailbreaking. In later provider authorization and token exchanges we can trust the - // ID as it's already been checked and it has been cryptographically protected against - // tamering. - if provider.Namespace != a.namespace && (organization == nil || provider.Namespace != organization.Status.Namespace) { - return nil, fmt.Errorf("%w: requested provider not allowed", ErrReference) - } - - return provider, nil -} - -// newOIDCProvider abstracts away any hacks for specific providers. -func newOIDCProvider(ctx context.Context, p *unikornv1.OAuth2Provider) (*oidc.Provider, error) { - if p.Spec.Type != nil && *p.Spec.Type == unikornv1.MicrosoftEntra { - ctx = oidc.InsecureIssuerURLContext(ctx, "https://login.microsoftonline.com/{tenantid}/v2.0") - } - - return oidc.NewProvider(ctx, p.Spec.Issuer) -} - // providerAuthenticationRequest takes a client provided email address and routes it // to the correct identity provider, if we can. -func (a *Authenticator) providerAuthenticationRequest(w http.ResponseWriter, r *http.Request, client *unikornv1.OAuth2Client, providerResource *unikornv1.OAuth2Provider, query url.Values, email string) { - driver := providers.New(providerResource.Spec.Type) - +func (a *Authenticator) providerAuthenticationRequest(w http.ResponseWriter, r *http.Request, client *unikornv1.OAuth2Client, provider *unikornv1.OAuth2Provider, query url.Values, email string) { // OIDC requires a nonce, just some random data base64 URL encoded will suffice. nonce, err := randomString(16) if err != nil { @@ -579,9 +516,9 @@ func (a *Authenticator) providerAuthenticationRequest(w http.ResponseWriter, r * // requires persistent state at the minimum, and a database in the case of multi-head // deployments, just encrypt it and send with the authoriation request. oidcState := &State{ - OAuth2Provider: providerResource.Name, + OAuth2Provider: provider.Name, Nonce: nonce, - CodeVerfier: codeVerifier, + CodeVerifier: codeVerifier, ClientID: query.Get("client_id"), ClientRedirectURI: query.Get("redirect_uri"), ClientState: query.Get("state"), @@ -603,36 +540,33 @@ func (a *Authenticator) providerAuthenticationRequest(w http.ResponseWriter, r * return } - // Take a short cut if using oauth2. - if useOauth2(providerResource) { - http.Redirect(w, r, oauth2Config(r.Host, providerResource, nil, nil).AuthCodeURL(state), http.StatusFound) - return + driver := providers.New(provider.Spec.Type) + + configParameters := &types.ConfigParameters{ + Host: r.Host, + Provider: provider, } - // Otherwise handle OIDC via endpoint discovery. - _, config, err := oidcConfig(r.Context(), r.Host, providerResource, driver.Scopes()) + config, err := driver.Config(r.Context(), configParameters) if err != nil { - authorizationError(w, r, client.Spec.RedirectURI, ErrorServerError, "unable to create oauth2 configuration: "+err.Error()) + authorizationError(w, r, client.Spec.RedirectURI, ErrorServerError, "unable to create oauth2 config: "+err.Error()) return } - authURLParams := []oauth2.AuthCodeOption{ - oauth2.SetAuthURLParam("code_challenge_method", "S256"), - oauth2.SetAuthURLParam("code_challenge", encodeCodeChallengeS256(codeVerifier)), - oidc.Nonce(nonce), - } - - // If the user provided an email as part of the loging screen, send that to the IdP to - // optimize the process. - if email != "" { - authURLParams = append(authURLParams, oauth2.SetAuthURLParam("login_hint", email)) + parameters := &types.AuthorizationParamters{ + Nonce: nonce, + State: state, + CodeChallenge: encodeCodeChallengeS256(codeVerifier), + Email: email, } - for k, v := range driver.AuthorizationRequestParameters() { - authURLParams = append(authURLParams, oauth2.SetAuthURLParam(k, v)) + url, err := driver.AuthorizationURL(config, parameters) + if err != nil { + authorizationError(w, r, client.Spec.RedirectURI, ErrorServerError, "unable to create oauth2 redirect: "+err.Error()) + return } - http.Redirect(w, r, config.AuthCodeURL(state, authURLParams...), http.StatusFound) + http.Redirect(w, r, url, http.StatusFound) } // Login handles the response from the user login prompt. @@ -670,6 +604,7 @@ func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request) { return } + // Handle the case where the provider is explicitly specified. if providerType := r.Form.Get("provider"); providerType != "" { provider, err := a.lookupProviderByType(r.Context(), unikornv1.IdentityProviderType(providerType)) if err != nil { @@ -682,6 +617,7 @@ func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request) { return } + // Otherwise we need to infer the provider. email := r.Form.Get("email") if email == "" { @@ -704,209 +640,6 @@ func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request) { a.providerAuthenticationRequest(w, r, client, provider, query, email) } -// oidcExtractIDToken wraps up token verification against the JWKS service and conversion -// to a concrete type. -func (a *Authenticator) oidcExtractIDToken(ctx context.Context, provider *oidc.Provider, providerResource *unikornv1.OAuth2Provider, token string) (*oidc.IDToken, error) { - config := &oidc.Config{ - ClientID: providerResource.Spec.ClientID, - // TODO: this is a Entra-ism - SkipIssuerCheck: true, - } - - idTokenVerifier := provider.Verifier(config) - - idToken, err := idTokenVerifier.Verify(ctx, token) - if err != nil { - return nil, err - } - - return idToken, nil -} - -//nolint:tagliatelle -type GitHubUser struct { - Name string `json:"name"` - AvatarURL string `json:"avatar_url"` -} - -type GitHubEmail struct { - Email string `json:"email"` - Verified bool `json:"verified"` - Primary bool `json:"primary"` -} - -const githubAPIBase = "https://api.github.com" - -type GitHubClient struct { - token string -} - -func NewGitHubClient(token string) *GitHubClient { - return &GitHubClient{ - token: token, - } -} - -func (g *GitHubClient) do(ctx context.Context, path string, data interface{}) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIBase+path, nil) - if err != nil { - return err - } - - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("Authorization", "bearer "+g.token) - req.Header.Set("X-Github-Api-Version", "2022-11-28") - - c := &http.Client{} - - resp, err := c.Do(req) - if err != nil { - return err - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if err := json.Unmarshal(body, data); err != nil { - return err - } - - return nil -} - -func (g *GitHubClient) GetUser(ctx context.Context) (*GitHubUser, error) { - user := &GitHubUser{} - - if err := g.do(ctx, "/user", user); err != nil { - return nil, err - } - - return user, nil -} - -func (g *GitHubClient) GetEmails(ctx context.Context) ([]GitHubEmail, error) { - var emails []GitHubEmail - - if err := g.do(ctx, "/user/emails", &emails); err != nil { - return nil, err - } - - return emails, nil -} - -func (g *GitHubClient) GetPrimaryEmail(ctx context.Context) (*GitHubEmail, error) { - emails, err := g.GetEmails(ctx) - if err != nil { - return nil, err - } - - i := slices.IndexFunc(emails, func(email GitHubEmail) bool { return email.Primary }) - if i < 0 { - return nil, ErrEmailLookup - } - - return &emails[i], nil -} - -func createGitHubIDToken(ctx context.Context, token string) (*IDToken, error) { - github := NewGitHubClient(token) - - // User gives us information about the user... - user, err := github.GetUser(ctx) - if err != nil { - return nil, err - } - - // ...but not always an email address. - email, err := github.GetPrimaryEmail(ctx) - if err != nil { - return nil, err - } - - out := &IDToken{ - OIDCClaimsProfile: OIDCClaimsProfile{ - Name: user.Name, - Picture: user.AvatarURL, - }, - OIDCClaimsEmail: OIDCClaimsEmail{ - Email: email.Email, - EmailVerified: email.Verified, - }, - } - - return out, nil -} - -// oauth2CodeExchange exchanges a code with a plain oauth2 server. -func (a *Authenticator) oauth2CodeExchange(ctx context.Context, provider *unikornv1.OAuth2Provider, host, code string) (*oauth2.Token, *IDToken, error) { - token, err := oauth2Config(host, provider, nil, nil).Exchange(ctx, code) - if err != nil { - return nil, nil, err - } - - if provider.Spec.Type == nil || *provider.Spec.Type != unikornv1.GitHub { - return nil, nil, fmt.Errorf("%w: %v", ErrUnsupportedProviderType, provider.Spec.Type) - } - - idToken, err := createGitHubIDToken(ctx, token.AccessToken) - if err != nil { - return nil, nil, err - } - - return token, idToken, nil -} - -// oidcCodeExchange exchanges a code with an IODC compliant server. -func (a *Authenticator) oidcCodeExchange(ctx context.Context, provider *unikornv1.OAuth2Provider, state *State, host, code string) (*oauth2.Token, *IDToken, error) { - oidcProvider, config, err := oidcConfig(ctx, host, provider, nil) - if err != nil { - return nil, nil, err - } - - // Exchange the code for an id_token, access_token and refresh_token with - // the extracted code verifier. - authURLParams := []oauth2.AuthCodeOption{ - oauth2.SetAuthURLParam("client_id", state.ClientID), - oauth2.SetAuthURLParam("code_verifier", state.CodeVerfier), - } - - token, err := config.Exchange(ctx, code, authURLParams...) - if err != nil { - return nil, nil, err - } - - idTokenRaw, ok := token.Extra("id_token").(string) - if !ok { - return nil, nil, err - } - - idToken, err := a.oidcExtractIDToken(ctx, oidcProvider, provider, idTokenRaw) - if err != nil { - return nil, nil, err - } - - idTokenClaims := &IDToken{} - - if err := idToken.Claims(idTokenClaims); err != nil { - return nil, nil, err - } - - return token, idTokenClaims, nil -} - -// codeExchange exchanges a code with any server in an abstract way. -func (a *Authenticator) codeExchange(ctx context.Context, provider *unikornv1.OAuth2Provider, state *State, host, code string) (*oauth2.Token, *IDToken, error) { - if useOauth2(provider) { - return a.oauth2CodeExchange(ctx, provider, host, code) - } - - return a.oidcCodeExchange(ctx, provider, state, host, code) -} - // OIDCCallback is called by the authorization endpoint in order to return an // authorization back to us. We then exchange the code for an ID token, and // refresh token. Remember, as far as the client is concerned we're still doing @@ -947,7 +680,16 @@ func (a *Authenticator) Callback(w http.ResponseWriter, r *http.Request) { return } - tokens, idToken, err := a.codeExchange(r.Context(), provider, state, r.Host, query.Get("code")) + parameters := &types.CodeExchangeParameters{ + ConfigParameters: types.ConfigParameters{ + Host: r.Host, + Provider: provider, + }, + Code: query.Get("code"), + CodeVerifier: state.CodeVerifier, + } + + tokens, idToken, err := providers.New(provider.Spec.Type).CodeExchange(r.Context(), parameters) if err != nil { authorizationError(w, r, state.ClientRedirectURI, ErrorServerError, "code exchange failed: "+err.Error()) return @@ -955,7 +697,7 @@ func (a *Authenticator) Callback(w http.ResponseWriter, r *http.Request) { // Only check rbac if we are not allowing unknown users. if !a.options.AuthenticateUnknownUsers { - userExists, err := a.rbac.UserExists(r.Context(), idToken.Email) + userExists, err := a.rbac.UserExists(r.Context(), idToken.Email.Email) if err != nil { authorizationError(w, r, state.ClientRedirectURI, ErrorServerError, "failed to perform RBAC user lookup: "+err.Error()) @@ -981,14 +723,6 @@ func (a *Authenticator) Callback(w http.ResponseWriter, r *http.Request) { AccessTokenExpiry: tokens.Expiry, } - driver := providers.New(provider.Spec.Type) - - // These can be big, see the provider comment for why. - if driver.RequiresAccessToken() { - oauth2Code.AccessToken = tokens.AccessToken - oauth2Code.RefreshToken = tokens.RefreshToken - } - code, err := a.issuer.EncodeJWEToken(r.Context(), oauth2Code, jose.TokenTypeAuthorizationCode) if err != nil { authorizationError(w, r, state.ClientRedirectURI, ErrorServerError, "failed to encode authorization code: "+err.Error()) @@ -1060,28 +794,28 @@ func (a *Authenticator) oidcIDToken(r *http.Request, code *Code, expiry time.Dur return nil, nil } - claims := &IDToken{ + claims := &oidc.IDToken{ Claims: jwt.Claims{ Issuer: "https://" + r.Host, - Subject: code.IDToken.OIDCClaimsEmail.Email, + Subject: code.IDToken.Email.Email, Audience: []string{ code.ClientID, }, Expiry: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), }, - OIDCClaims: OIDCClaims{ + Default: oidc.Default{ Nonce: code.ClientNonce, ATHash: atHash, }, } if slices.Contains(code.ClientScope, "email") { - claims.OIDCClaimsEmail = code.IDToken.OIDCClaimsEmail + claims.Email = code.IDToken.Email } if slices.Contains(code.ClientScope, "profile") { - claims.OIDCClaimsProfile = code.IDToken.OIDCClaimsProfile + claims.Profile = code.IDToken.Profile } idToken, err := a.issuer.EncodeJWT(r.Context(), claims) @@ -1112,19 +846,14 @@ func (a *Authenticator) TokenAuthorizationCode(w http.ResponseWriter, r *http.Re info := &IssueInfo{ Issuer: "https://" + r.Host, Audience: r.Host, - Subject: code.IDToken.OIDCClaimsEmail.Email, + Subject: code.IDToken.Email.Email, ClientID: code.ClientID, Federated: &Federated{ - Provider: code.OAuth2Provider, - Expiry: code.AccessTokenExpiry, - AccessToken: code.AccessToken, + Provider: code.OAuth2Provider, + Expiry: code.AccessTokenExpiry, }, } - if code.RefreshToken != "" { - info.Federated.RefreshToken = &code.RefreshToken - } - tokens, err := a.Issue(r.Context(), info) if err != nil { return nil, err @@ -1147,39 +876,6 @@ func (a *Authenticator) TokenAuthorizationCode(w http.ResponseWriter, r *http.Re return result, nil } -// tokenRefreshConfig selects the correct configuration for a token refresh. -func tokenRefreshConfig(ctx context.Context, provider *unikornv1.OAuth2Provider, host string) (*oauth2.Config, error) { - if useOauth2(provider) { - return oauth2Config(host, provider, nil, nil), nil - } - - var config *oauth2.Config - - // Quality of life improvement, when you are a road-warrior, you are going - // to get an expired access token almost immediately, and a token refresh - // well before Wifi comes up, so allow retries while DNS errors are - // occurring, within reason. - callback := func() error { - _, c, err := oidcConfig(ctx, host, provider, nil) - if err != nil { - return err - } - - config = c - - return nil - } - - retryContext, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if err := retry.Forever().DoWithContext(retryContext, callback); err != nil { - return nil, err - } - - return config, nil -} - // TokenRefreshToken issues a token if the provided refresh token is valid. func (a *Authenticator) TokenRefreshToken(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { // Validate the refresh token and extract the claims. @@ -1196,7 +892,12 @@ func (a *Authenticator) TokenRefreshToken(w http.ResponseWriter, r *http.Request return nil, err } - config, err := tokenRefreshConfig(r.Context(), provider, r.Host) + parameters := &types.ConfigParameters{ + Host: r.Host, + Provider: provider, + } + + config, err := providers.New(provider.Spec.Type).Config(r.Context(), parameters) if err != nil { return nil, errors.OAuth2ServerError("failed to get oauth2 config").WithError(err) } diff --git a/pkg/oauth2/oidc/authorization_code.go b/pkg/oauth2/oidc/authorization_code.go new file mode 100644 index 0000000..3d04f3d --- /dev/null +++ b/pkg/oauth2/oidc/authorization_code.go @@ -0,0 +1,109 @@ +/* +Copyright 2025 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package oidc + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/common" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" +) + +var ( + ErrMissingField = errors.New("missing field") +) + +// Config returns a oauth2 configuration via service discovery. +func Config(ctx context.Context, parameters *types.ConfigParameters, scopes []string) (*oidc.Provider, *oauth2.Config, error) { + oidcProvider, err := oidc.NewProvider(ctx, parameters.Provider.Spec.Issuer) + if err != nil { + return nil, nil, err + } + + scopes = slices.Concat([]string{oidc.ScopeOpenID, "profile", "email"}, scopes) + + config := common.Config(parameters, scopes) + config.Endpoint = oidcProvider.Endpoint() + + return oidcProvider, config, nil +} + +// Authorization gets the oauth2 authorization URL. +func Authorization(config *oauth2.Config, parameters *types.AuthorizationParamters, requestParameters []oauth2.AuthCodeOption) (string, error) { + requestParameters = append(requestParameters, + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + oauth2.SetAuthURLParam("code_challenge", parameters.CodeChallenge), + oidc.Nonce(parameters.Nonce), + ) + + // If the user provided an email as part of the loging screen, send that to the IdP to + // optimize the process. + if parameters.Email != "" { + requestParameters = append(requestParameters, oauth2.SetAuthURLParam("login_hint", parameters.Email)) + } + + return common.Authorization(config, parameters, requestParameters), nil +} + +// CodeExchange exchanges a code with an OIDC compliant server. +func CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *IDToken, error) { + oidcProvider, config, err := Config(ctx, ¶meters.ConfigParameters, nil) + if err != nil { + return nil, nil, err + } + + // Exchange the code for an id_token, access_token and refresh_token with + // the extracted code verifier. + authURLParams := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("client_id", parameters.Provider.Spec.ClientID), + oauth2.SetAuthURLParam("code_verifier", parameters.CodeVerifier), + } + + token, err := config.Exchange(ctx, parameters.Code, authURLParams...) + if err != nil { + return nil, nil, err + } + + idTokenRaw, ok := token.Extra("id_token").(string) + if !ok { + return nil, nil, fmt.Errorf("%w: id_token not in response", ErrMissingField) + } + + oidcConfig := &oidc.Config{ + ClientID: parameters.Provider.Spec.ClientID, + SkipIssuerCheck: parameters.SkipIssuerCheck, + } + + idToken, err := oidcProvider.Verifier(oidcConfig).Verify(ctx, idTokenRaw) + if err != nil { + return nil, nil, err + } + + idTokenClaims := &IDToken{} + + if err := idToken.Claims(idTokenClaims); err != nil { + return nil, nil, err + } + + return token, idTokenClaims, nil +} diff --git a/pkg/oauth2/oidc.go b/pkg/oauth2/oidc/types.go similarity index 81% rename from pkg/oauth2/oidc.go rename to pkg/oauth2/oidc/types.go index 2e152a7..89af1c2 100644 --- a/pkg/oauth2/oidc.go +++ b/pkg/oauth2/oidc/types.go @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package oauth2 +package oidc import ( "github.com/go-jose/go-jose/v3/jwt" ) -// OIDCClaims are claims defined by OIDC to be in an id_token. +// Default are claims defined by default for an id_token. // //nolint:tagliatelle -type OIDCClaims struct { +type Default struct { // Nonce should match the nonce provided by the client at authorization // time and should be verfified against the original nonce. Nonce string `json:"nonce,omitempty"` @@ -32,11 +32,11 @@ type OIDCClaims struct { ATHash string `json:"at_hash,omitempty"` } -// OIDCClaimsProfile are claims that may be returned by requesting the +// Profile are claims that may be returned by requesting the // profile scope. // //nolint:tagliatelle -type OIDCClaimsProfile struct { +type Profile struct { // Name is the user's full name. Name string `json:"name,omitempty"` // GivenName is the user's forename. @@ -68,11 +68,11 @@ type OIDCClaimsProfile struct { UpdatedAt string `json:"updated_at,omitempty"` } -// OIDCClaimsEmail are claims that make be returned by requesting the +// Email are claims that make be returned by requesting the // email scope. // //nolint:tagliatelle -type OIDCClaimsEmail struct { +type Email struct { // Email is the user's email address. Email string `json:"email,omitempty"` // EmailVerified indicates whether this email address has been verified @@ -80,14 +80,14 @@ type OIDCClaimsEmail struct { EmailVerified bool `json:"email_verified,omitempty"` } -// IDToken defines an OIDC id_token. +// IDToken defines an id_token. type IDToken struct { // Claims are the standard claims expected in a JWT. jwt.Claims `json:",inline"` - // OIDC claims are claims defined by OIDC to be in an id_token. - OIDCClaims `json:",inline"` - // OIDCClaimsProfile are claims returned by the "profile" scope. - OIDCClaimsProfile `json:",inline"` - // OIDCClaimsEmail are claims returned by the "email" scope. - OIDCClaimsEmail `json:",inline"` + // Default are claims defined by default for an id_token. + Default `json:",inline"` + // Profile are claims returned by the "profile" scope. + Profile `json:",inline"` + // Email are claims returned by the "email" scope. + Email `json:",inline"` } diff --git a/pkg/oauth2/providers/factory.go b/pkg/oauth2/providers/factory.go index ff72504..0671322 100644 --- a/pkg/oauth2/providers/factory.go +++ b/pkg/oauth2/providers/factory.go @@ -18,7 +18,9 @@ package providers import ( unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/identity/pkg/oauth2/providers/github" "github.com/unikorn-cloud/identity/pkg/oauth2/providers/google" + "github.com/unikorn-cloud/identity/pkg/oauth2/providers/microsoft" ) func New(providerType *unikornv1.IdentityProviderType) Provider { @@ -26,10 +28,13 @@ func New(providerType *unikornv1.IdentityProviderType) Provider { return newNullProvider() } - //nolint:gocritic,exhaustive switch *providerType { case unikornv1.GoogleIdentity: return google.New() + case unikornv1.MicrosoftEntra: + return microsoft.New() + case unikornv1.GitHub: + return github.New() } return newNullProvider() diff --git a/pkg/oauth2/providers/github/provider.go b/pkg/oauth2/providers/github/provider.go new file mode 100644 index 0000000..61052fc --- /dev/null +++ b/pkg/oauth2/providers/github/provider.go @@ -0,0 +1,180 @@ +/* +Copyright 2025 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package github + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "slices" + + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/common" + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" +) + +var ( + ErrEmailLookup = errors.New("failed to lookup email") +) + +//nolint:tagliatelle +type User struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +type Email struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` +} + +const githubAPIBase = "https://api.github.com" + +type Client struct { + token string +} + +func NewClient(token string) *Client { + return &Client{ + token: token, + } +} + +func (p *Client) do(ctx context.Context, path string, data interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIBase+path, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "bearer "+p.token) + req.Header.Set("X-Github-Api-Version", "2022-11-28") + + c := &http.Client{} + + resp, err := c.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if err := json.Unmarshal(body, data); err != nil { + return err + } + + return nil +} + +func (p *Client) GetUser(ctx context.Context) (*User, error) { + user := &User{} + + if err := p.do(ctx, "/user", user); err != nil { + return nil, err + } + + return user, nil +} + +func (p *Client) GetEmails(ctx context.Context) ([]Email, error) { + var emails []Email + + if err := p.do(ctx, "/user/emails", &emails); err != nil { + return nil, err + } + + return emails, nil +} + +func (p *Client) GetPrimaryEmail(ctx context.Context) (*Email, error) { + emails, err := p.GetEmails(ctx) + if err != nil { + return nil, err + } + + i := slices.IndexFunc(emails, func(email Email) bool { return email.Primary }) + if i < 0 { + return nil, ErrEmailLookup + } + + return &emails[i], nil +} + +func (p *Client) IDToken(ctx context.Context) (*oidc.IDToken, error) { + // User gives us information about the user... + user, err := p.GetUser(ctx) + if err != nil { + return nil, err + } + + // ...but not always an email address. + email, err := p.GetPrimaryEmail(ctx) + if err != nil { + return nil, err + } + + out := &oidc.IDToken{ + Profile: oidc.Profile{ + Name: user.Name, + Picture: user.AvatarURL, + }, + Email: oidc.Email{ + Email: email.Email, + EmailVerified: email.Verified, + }, + } + + return out, nil +} + +type Provider struct{} + +func New() *Provider { + return &Provider{} +} + +func (*Provider) Config(ctx context.Context, parameters *types.ConfigParameters) (*oauth2.Config, error) { + return common.Config(parameters, nil), nil +} + +func (*Provider) AuthorizationURL(config *oauth2.Config, parameters *types.AuthorizationParamters) (string, error) { + return common.Authorization(config, parameters, nil), nil +} + +func (*Provider) CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *oidc.IDToken, error) { + token, err := common.CodeExchange(ctx, parameters) + if err != nil { + return nil, nil, err + } + + idToken, err := NewClient(token.AccessToken).IDToken(ctx) + if err != nil { + return nil, nil, err + } + + return token, idToken, nil +} diff --git a/pkg/oauth2/providers/google/provider.go b/pkg/oauth2/providers/google/provider.go index 7d4d37b..56a977b 100644 --- a/pkg/oauth2/providers/google/provider.go +++ b/pkg/oauth2/providers/google/provider.go @@ -19,8 +19,10 @@ package google import ( "context" - unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" - "github.com/unikorn-cloud/identity/pkg/oauth2/providers/types" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" ) type Provider struct{} @@ -29,24 +31,24 @@ func New() *Provider { return &Provider{} } -func (*Provider) AuthorizationRequestParameters() map[string]string { +func (*Provider) Config(ctx context.Context, parameters *types.ConfigParameters) (*oauth2.Config, error) { + _, config, err := oidc.Config(ctx, parameters, nil) + + return config, err +} + +func (*Provider) AuthorizationURL(config *oauth2.Config, parameters *types.AuthorizationParamters) (string, error) { // This grants us access to a refresh token. // See: https://developers.google.com/identity/openid-connect/openid-connect#access-type-param // And: https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token - return map[string]string{ - "prompt": "consent", - "access_type": "offline", + requestParameters := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("prompt", "consent"), + oauth2.SetAuthURLParam("access_type", "offline"), } -} - -func (*Provider) Scopes() []string { - return nil -} -func (*Provider) RequiresAccessToken() bool { - return true + return oidc.Authorization(config, parameters, requestParameters) } -func (p *Provider) Groups(ctx context.Context, organization *unikornv1.Organization, accessToken string) ([]types.Group, error) { - return nil, nil +func (*Provider) CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *oidc.IDToken, error) { + return oidc.CodeExchange(ctx, parameters) } diff --git a/pkg/oauth2/providers/interfaces.go b/pkg/oauth2/providers/interfaces.go index a2ef953..1f1261a 100644 --- a/pkg/oauth2/providers/interfaces.go +++ b/pkg/oauth2/providers/interfaces.go @@ -19,28 +19,17 @@ package providers import ( "context" - unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" - "github.com/unikorn-cloud/identity/pkg/oauth2/providers/types" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" ) type Provider interface { - // AuthorizationRequestParameters allows the autorization request parameters - // to be tweaked on a per-provider basis. - AuthorizationRequestParameters() map[string]string - - // Scopes returns a set of scopes that are required by the access token - // to operate correctly. - Scopes() []string - - // RequiresAccessToken defines whether the access and refresh tokens are - // required for operation. - // TODO: this is because Microsoft's tokens are massive and blow nginx's - // request size limit (4096). We really need to cache these securely and - // internally so we don't have to pass them around. For example hand to - // the client an ID and private key that can decrpyt from storage, in memory - // on demand. - RequiresAccessToken() bool - - // Groups returns a list of groups the user belongs to. - Groups(ctx context.Context, organization *unikornv1.Organization, accessToken string) ([]types.Group, error) + // Config returns an oauth2 configuration. + Config(ctx context.Context, parameters *types.ConfigParameters) (*oauth2.Config, error) + // Authorization gets the oauth2 authorization URL. + AuthorizationURL(config *oauth2.Config, parameters *types.AuthorizationParamters) (string, error) + // CodeExchange exchanges a code with an oauth2 server and returns a (possibly emulated) OIDC ID token. + CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *oidc.IDToken, error) } diff --git a/pkg/oauth2/providers/microsoft/provider.go b/pkg/oauth2/providers/microsoft/provider.go new file mode 100644 index 0000000..7104766 --- /dev/null +++ b/pkg/oauth2/providers/microsoft/provider.go @@ -0,0 +1,55 @@ +/* +Copyright 2024-2025 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package microsoft + +import ( + "context" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" +) + +type Provider struct{} + +func New() *Provider { + return &Provider{} +} + +func (*Provider) Config(ctx context.Context, parameters *types.ConfigParameters) (*oauth2.Config, error) { + // Handle non-stardard issuers. + ctx = gooidc.InsecureIssuerURLContext(ctx, "https://login.microsoftonline.com/{tenantid}/v2.0") + + _, config, err := oidc.Config(ctx, parameters, nil) + + return config, err +} + +func (*Provider) AuthorizationURL(config *oauth2.Config, parameters *types.AuthorizationParamters) (string, error) { + return oidc.Authorization(config, parameters, nil) +} + +func (*Provider) CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *oidc.IDToken, error) { + // Handle non-stardard issuers. + ctx = gooidc.InsecureIssuerURLContext(ctx, "https://login.microsoftonline.com/{tenantid}/v2.0") + + parameters.SkipIssuerCheck = true + + return oidc.CodeExchange(ctx, parameters) +} diff --git a/pkg/oauth2/providers/null.go b/pkg/oauth2/providers/null.go index ee1c4b2..f81b71f 100644 --- a/pkg/oauth2/providers/null.go +++ b/pkg/oauth2/providers/null.go @@ -19,8 +19,10 @@ package providers import ( "context" - unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" - "github.com/unikorn-cloud/identity/pkg/oauth2/providers/types" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/identity/pkg/oauth2/oidc" + "github.com/unikorn-cloud/identity/pkg/oauth2/types" ) // nullProvider does nothing. @@ -30,18 +32,16 @@ func newNullProvider() Provider { return &nullProvider{} } -func (*nullProvider) AuthorizationRequestParameters() map[string]string { - return nil -} +func (*nullProvider) Config(ctx context.Context, parameters *types.ConfigParameters) (*oauth2.Config, error) { + _, config, err := oidc.Config(ctx, parameters, nil) -func (*nullProvider) Scopes() []string { - return nil + return config, err } -func (*nullProvider) RequiresAccessToken() bool { - return false +func (*nullProvider) AuthorizationURL(config *oauth2.Config, parameters *types.AuthorizationParamters) (string, error) { + return oidc.Authorization(config, parameters, nil) } -func (*nullProvider) Groups(ctx context.Context, organization *unikornv1.Organization, accessToken string) ([]types.Group, error) { - return nil, nil +func (*nullProvider) CodeExchange(ctx context.Context, parameters *types.CodeExchangeParameters) (*oauth2.Token, *oidc.IDToken, error) { + return oidc.CodeExchange(ctx, parameters) } diff --git a/pkg/oauth2/providers/types/types.go b/pkg/oauth2/providers/types/types.go deleted file mode 100644 index 0d25d9a..0000000 --- a/pkg/oauth2/providers/types/types.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024-2025 the Unikorn Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package types - -type Group struct { - Name string - - DisplayName *string -} diff --git a/pkg/oauth2/types/types.go b/pkg/oauth2/types/types.go new file mode 100644 index 0000000..651a339 --- /dev/null +++ b/pkg/oauth2/types/types.go @@ -0,0 +1,61 @@ +/* +Copyright 2024-2025 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" +) + +// ConfigParameters are common parameters when creating an oauth2 client. +type ConfigParameters struct { + // Host is the current HTTP 1.1 hostname. + Host string + // Provider describes the oauth2 provider. + Provider *unikornv1.OAuth2Provider +} + +// AuthorizationParamters are common parameters when starting the oauth2 +// authorization code flow. +type AuthorizationParamters struct { + // State is the state that's preserved across the authorization. + State string + // CodeChallenge is the PKCE code challenge used to compare against + // the one supplied during exchange. OIDC only. + CodeChallenge string + // Nonce is the single use value required by OIDC that's encoded + // in the id_token. OIDC only. + Nonce string + // Email address of the user, used to inject the login_hint. + // OIDC only. + Email string +} + +// CodeExchangeParameters are common parameters when performing the oauth2 +// code exchange. +type CodeExchangeParameters struct { + // ConfigParameters are used to contact the authorization server + // and also for ID token validation when using OIDC. + ConfigParameters + // Code is the code returned by the authorization server. + Code string + // CodeVerifier is the corresponding key to the authorization code + // challenge that proves we requested the token. + CodeVerifier string + // SkipIssuerCheck is a hack for non-compliant providers like + // microsoft. OIDC only. + SkipIssuerCheck bool +}