mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
* feat(api): hard-fork CRD API group argoproj.io -> deploy.hanzo.ai
Hanzo CD hard-fork, phase 1.1. Single source of truth is
pkg/apis/application/register.go's Group constant (Application/AppProject/
ApplicationSet all derive their FullName from it); every other Go call-site
that hardcoded the literal group string is updated to match: the 3 generated
informer GVRs, the app-of-apps nested-Application detector in types.go, the
resource-tree Application-kind cache populator, the 3 NotFound error
constructors in server/application, and the applicationset-controller's
leader-election Lease name/ID (kept in lockstep across Go source + RBAC
resourceNames, or leader election would get RBAC-denied on startup).
CRD YAMLs (application/appproject/applicationset) and every RBAC apiGroups
rule that actually governs our 3 CRDs are repointed to deploy.hanzo.ai, across
both manifests/base/* (namespaced Roles) and manifests/cluster-rbac/* (cluster
scope). Two adjacent things that share the "argoproj.io" string but are NOT
ours are deliberately left untouched: gitops-engine's health-check dispatch
for Argo Workflows' `Workflow` kind, and argocd-server-clusterrole's second
apiGroups block granting read on Argo Workflows' `workflows` resource — both
belong to a separate, unrelated Argoproj-family project we are not forking.
Also untouched, per explicit scope: annotation/label keys (argocd.argoproj.io/*)
and finalizers (*.argoproj.io) that track already-applied live resources, and
argocd-cm.yaml's resource.customizations.ignoreResourceUpdates keys (a narrow
app-of-apps diffing nicety, not build- or RBAC-relevant).
CGO_ENABLED=0 go build ./... verified green.
* ci: wire canonical hanzo.yml + cicd.yml (Hanzo CD image build)
Phase 1.3. Follows the same one-way pattern every hanzo/lux/zoo repo uses:
hanzo.yml declares the image (root Dockerfile -> ghcr.io/hanzoai/deploy) and a
fast go-build sanity test (the multi-stage Dockerfile build is the slow, real
gate); cicd.yml is the ~10-line trigger shim importing hanzoai/ci's reusable
build workflow on the arc pool, secrets from KMS only.
No `deploy:` block yet, matching the hanzoai/cms build-only-first-cutover
pattern: this repo's own live hanzo-cd namespace runs the whole Hanzo fleet's
GitOps, so the prod image/CRD swap is a deliberate separate action (see the
[HELD] universe PR), never an automatic rollout on merge. Every PR/push still
builds and pushes an immutable ghcr.io/hanzoai/deploy:sha-<7>-amd64 image.
* feat(ui): rebrand Argo CD -> Hanzo CD (title, favicon, logo, sidebar)
Phase 1.2. User-facing "Argo CD"/"ArgoCD" text replaced with "Hanzo CD"
across the login page, help modal, settings screens, version-info panel,
sync-options warning, fullscreen-logs title, app.tsx page titles, and
index.html's <title>/noscript text (dropped the now-wrong upstream CLI
doc link rather than invent a hanzo.ai URL that doesn't exist).
Logo/favicon assets replaced with the canonical Hanzo mark (never
hand-drawn, copied from ~/work/hanzo/logo/dist): the two real usages of
Argo's logo collapse onto one Hanzo glyph (mono + white variants) at the
same 5 call sites (login icon + footer, login.scss $logoPath/
$logoNegativePath, sidebar text-logo), preserving the existing CSS
invert() filters so both the light login panel and dark sidebar still
render correctly. Old argologo.svg/argo_o.svg/argo.png deleted (no
longer referenced). favicon-{16x16,32x32}.png added at
ui/src/assets/favicon/ (index.html's links to that path were previously
dead - the directory didn't exist in this checkout). package.json name
argo-cd-ui -> hanzo-cd-ui.
Left alone, deliberately: the two Kubernetes resource-kind icon sets
under ui/src/assets/images/resources/ (unrelated to branding), and
upstream doc/project links (argoproj.io, argo-cd.readthedocs.io) since
we have no equivalent hanzo.ai docs to point to yet - except the login
footer logo's outbound link, which now points to hanzo.ai instead of
argoproj.io (a Hanzo-branded mark linking to the upstream project it
was forked from read as an obvious leftover).
* feat(module): rename Go module github.com/argoproj/argo-cd/v3 -> github.com/hanzoai/deploy
Phase 2. No version suffix on the new path (org policy: never publish our
own Go modules above v1.x.x / with a v2+ suffix) — this fork starts a fresh
v0/v1 module identity rather than carrying the v3 the upstream fork point
had reached.
Mechanical, symmetric rewrite: 704 .go files (import statements only; two
disjoint substitutions applied specific-first so the nested gitops-engine
module path never double-matches), plus go.mod (module line, the
gitops-engine require+replace pair, version reset to v0.0.0-... since a
suffix-less path can't carry a v3 semver), gitops-engine/go.mod, Makefile
(ldflags PACKAGE + 2 TEST_MODULE filters), .golangci.yaml, .goreleaser.yaml,
.mockery.yaml (+ gitops-engine's), Procfile, the codegen/proto generation
scripts (hack/generate-proto.sh, update-codegen.sh, update-openapi.sh), and
20 .proto files' go_package options. Also one OpenTelemetry tracer name
string in gitops-engine/pkg/sync/sync_context.go (a cosmestic label, not an
import, but worth keeping consistent).
CGO_ENABLED=0 go build ./... verified green for both the root module and
the nested gitops-engine module; go vet clean on both; go mod tidy run
(network available) to reconcile go.sum, dependency set unchanged.
Left alone, deliberately: doc-only mentions (docs/snyk/*.html scan reports,
gitops-engine/README.md), a comment-only reference in
.github/workflows/release.yaml, and one Renovate bot config line — none are
build- or codegen-load-bearing. Also left alone: comments citing upstream
GitHub issue URLs (github.com/argoproj/argo-cd/issues/NNNNN) and a handful
of test fixtures using "https://github.com/argoproj/argo-cd.git" as a
representative example repo URL — both are accurate historical/test-data
references, not stale self-references.
---------
Co-authored-by: Hanzo Dev <[email protected]>
1137 lines
40 KiB
Go
1137 lines
40 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/otel/codes"
|
|
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/hanzoai/deploy/common"
|
|
"github.com/hanzoai/deploy/server/settings/oidc"
|
|
"github.com/hanzoai/deploy/util/cache"
|
|
"github.com/hanzoai/deploy/util/crypto"
|
|
"github.com/hanzoai/deploy/util/dex"
|
|
|
|
httputil "github.com/hanzoai/deploy/util/http"
|
|
jwtutil "github.com/hanzoai/deploy/util/jwt"
|
|
"github.com/hanzoai/deploy/util/rand"
|
|
"github.com/hanzoai/deploy/util/settings"
|
|
)
|
|
|
|
var ErrInvalidRedirectURL = errors.New("invalid return URL")
|
|
|
|
// OpenTelemetry tracer for this package
|
|
var tracer trace.Tracer
|
|
|
|
func init() {
|
|
tracer = otel.Tracer("github.com/hanzoai/deploy/util/oidc")
|
|
}
|
|
|
|
const (
|
|
GrantTypeAuthorizationCode = "authorization_code"
|
|
GrantTypeImplicit = "implicit"
|
|
ResponseTypeCode = "code"
|
|
UserInfoResponseCachePrefix = "userinfo_response"
|
|
AccessTokenCachePrefix = "access_token"
|
|
OidcTokenCachePrefix = "oidc_token"
|
|
)
|
|
|
|
// OIDCConfiguration holds a subset of interested fields from the OIDC configuration spec
|
|
type OIDCConfiguration struct {
|
|
Issuer string `json:"issuer"`
|
|
ScopesSupported []string `json:"scopes_supported"`
|
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
|
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
|
|
}
|
|
|
|
type ClaimsRequest struct {
|
|
IDToken map[string]*oidc.Claim `json:"id_token"`
|
|
}
|
|
|
|
type ClientApp struct {
|
|
// OAuth2 client ID of this application (e.g. argo-cd)
|
|
clientID string
|
|
// OAuth2 client secret of this application
|
|
clientSecret string
|
|
// Use Proof Key for Code Exchange (PKCE)
|
|
usePKCE bool
|
|
// Use Azure Workload Identity for clientID auth instead of clientSecret
|
|
useAzureWorkloadIdentity bool
|
|
// Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback)
|
|
redirectURI string
|
|
// URL of the issuer (e.g. https://argocd.example.com/api/dex)
|
|
issuerURL string
|
|
// The URL endpoint at which the ArgoCD server is accessed.
|
|
baseHRef string
|
|
// client is the HTTP client which is used to query the IDp
|
|
client *http.Client
|
|
// secureCookie indicates if the cookie should be set with the Secure flag, meaning it should
|
|
// only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI.
|
|
secureCookie bool
|
|
// settings holds Argo CD settings
|
|
settings *settings.ArgoCDSettings
|
|
// encryptionKey holds server encryption key
|
|
encryptionKey []byte
|
|
// provider is the OIDC provider
|
|
provider Provider
|
|
// clientCache represent a cache of sso artifact
|
|
clientCache cache.CacheClient
|
|
// domainHint is an optional hint to the identity provider about the domain the user belongs to.
|
|
// Used to pre-fill or streamline the login experience (e.g., for Azure AD multi-tenant scenarios).
|
|
domainHint string
|
|
azure azureApp
|
|
// preemptive token refresh threshold
|
|
refreshTokenThreshold time.Duration
|
|
}
|
|
|
|
type azureApp struct {
|
|
// federated azure token for the service account
|
|
assertion string
|
|
// expiry of the token
|
|
expires time.Time
|
|
// mutex for parallelism for reading the token
|
|
mtx *sync.RWMutex
|
|
}
|
|
|
|
// OidcTokenCache is a serialization wrapper around oauth2 provider configuration needed to generate a TokenSource
|
|
type OidcTokenCache struct {
|
|
// Redirect URL is needed for oauth2 config initialization
|
|
RedirectURL string `json:"redirect_url"`
|
|
// oauth2 Token
|
|
Token *oauth2.Token `json:"token"`
|
|
// TokenExtraIdToken captures value of id_token
|
|
TokenExtraIdToken string `json:"token_extra_id_token"`
|
|
// SessionStart records when the OIDC session was first established
|
|
SessionStart time.Time `json:"session_start"`
|
|
}
|
|
|
|
// SessionDuration bounded TTL to ensure refreshed sessions
|
|
// cannot extend beyond the originally configured session lifetime
|
|
func sessionRemainingTTL(sessionStart time.Time, sessionDuration time.Duration) time.Duration {
|
|
if sessionStart.IsZero() {
|
|
return sessionDuration
|
|
}
|
|
remaining := time.Until(sessionStart.Add(sessionDuration))
|
|
if remaining < 0 {
|
|
return 0
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
// NewOidcTokenCache initializes the struct from a redirect URL, an existing token, and the session start time
|
|
func NewOidcTokenCache(redirectURL string, token *oauth2.Token, sessionStart time.Time) *OidcTokenCache {
|
|
var idToken string
|
|
if token.Extra("id_token") == nil {
|
|
idToken = ""
|
|
} else {
|
|
idToken = token.Extra("id_token").(string)
|
|
}
|
|
return &OidcTokenCache{
|
|
RedirectURL: redirectURL,
|
|
Token: token,
|
|
TokenExtraIdToken: idToken,
|
|
SessionStart: sessionStart,
|
|
}
|
|
}
|
|
|
|
// GetOidcTokenCacheFromJSON deserializes the json representation of OidcTokenCache. The Token extra map is updated from
|
|
// the serialization wrapper to propagate the id_token. This will ensure that the TokenSource always retrieves a usable token.
|
|
func GetOidcTokenCacheFromJSON(jsonBytes []byte) (*OidcTokenCache, error) {
|
|
var newToken OidcTokenCache
|
|
err := json.Unmarshal(jsonBytes, &newToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if newToken.Token == nil {
|
|
return nil, errors.New("empty token")
|
|
}
|
|
newToken.Token = newToken.Token.WithExtra(map[string]any{
|
|
"id_token": newToken.TokenExtraIdToken,
|
|
})
|
|
return &newToken, nil
|
|
}
|
|
|
|
// GetTokenSourceFromCache creates an oauth2 TokenSource from a cached oidc token. The TokenSource will be configured
|
|
// with an early expiration based on the refreshTokenThreshold.
|
|
func (a *ClientApp) GetTokenSourceFromCache(ctx context.Context, oidcTokenCache *OidcTokenCache) (oauth2.TokenSource, error) {
|
|
var span trace.Span
|
|
ctx, span = tracer.Start(ctx, "oidc.ClientApp.GetTokenSourceFromCache")
|
|
defer span.End()
|
|
if oidcTokenCache == nil {
|
|
return nil, errors.New("oidcTokenCache is required")
|
|
}
|
|
config, err := a.getOauth2ConfigForRedirectURI(oidcTokenCache.RedirectURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseTokenSource := config.TokenSource(ctx, oidcTokenCache.Token)
|
|
tokenRefresher := oauth2.ReuseTokenSourceWithExpiry(oidcTokenCache.Token, baseTokenSource, a.refreshTokenThreshold)
|
|
return tokenRefresher, nil
|
|
}
|
|
|
|
func GetScopesOrDefault(scopes []string) []string {
|
|
if len(scopes) == 0 {
|
|
return []string{"openid", "profile", "email", "groups"}
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
func getDomainHint(settings *settings.ArgoCDSettings) string {
|
|
oidcConfig := settings.OIDCConfig()
|
|
if oidcConfig != nil {
|
|
return strings.TrimSpace(oidcConfig.DomainHint)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// NewClientApp will register the Argo CD client app (either via Dex or external OIDC) and return an
|
|
// object which has HTTP handlers for handling the HTTP responses for login and callback
|
|
func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTLSConfig *dex.DexTLSConfig, baseHRef string, cacheClient cache.CacheClient) (*ClientApp, error) {
|
|
redirectURL, err := settings.RedirectURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encryptionKey, err := settings.GetServerEncryptionKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domainHint := getDomainHint(settings)
|
|
a := ClientApp{
|
|
clientID: settings.OAuth2ClientID(),
|
|
clientSecret: settings.OAuth2ClientSecret(),
|
|
usePKCE: settings.OAuth2UsePKCE(),
|
|
useAzureWorkloadIdentity: settings.UseAzureWorkloadIdentity(),
|
|
redirectURI: redirectURL,
|
|
issuerURL: settings.IssuerURL(),
|
|
baseHRef: baseHRef,
|
|
encryptionKey: encryptionKey,
|
|
clientCache: cacheClient,
|
|
azure: azureApp{mtx: &sync.RWMutex{}},
|
|
domainHint: domainHint,
|
|
refreshTokenThreshold: settings.RefreshTokenThreshold(),
|
|
}
|
|
log.Infof("Creating client app (%s)", a.clientID)
|
|
u, err := url.Parse(settings.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse redirect-uri: %w", err)
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
_, span := tracer.Start(ctx, "oidc.ClientApp.client")
|
|
defer span.End()
|
|
span.SetAttributes(
|
|
attribute.String("network", network),
|
|
attribute.String("addr", addr),
|
|
)
|
|
return (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).Dial(network, addr)
|
|
},
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
a.client = &http.Client{
|
|
Transport: transport,
|
|
}
|
|
|
|
if settings.DexConfig != "" && settings.OIDCConfigRAW == "" {
|
|
transport.TLSClientConfig = dex.TLSConfig(dexTLSConfig)
|
|
addrWithProto := dex.DexServerAddressWithProtocol(dexServerAddr, dexTLSConfig)
|
|
a.client.Transport = dex.NewDexRewriteURLRoundTripper(addrWithProto, a.client.Transport)
|
|
} else {
|
|
transport.TLSClientConfig = settings.OIDCTLSConfig()
|
|
}
|
|
if os.Getenv(common.EnvVarSSODebug) == "1" {
|
|
a.client.Transport = httputil.DebugTransport{T: a.client.Transport}
|
|
}
|
|
|
|
a.provider = NewOIDCProvider(a.issuerURL, a.client)
|
|
// NOTE: if we ever have replicas of Argo CD, this needs to switch to Redis cache
|
|
a.secureCookie = bool(u.Scheme == "https")
|
|
a.settings = settings
|
|
return &a, nil
|
|
}
|
|
|
|
func (a *ClientApp) getRedirectURIForRequest(req *http.Request) string {
|
|
redirectURI, err := a.settings.RedirectURLForRequest(req)
|
|
if err != nil {
|
|
log.Warnf("Unable to find ArgoCD URL from request, falling back to configured redirect URI: %v", err)
|
|
redirectURI = a.redirectURI
|
|
}
|
|
return redirectURI
|
|
}
|
|
|
|
func (a *ClientApp) getOauth2ConfigForRedirectURI(redirectURI string) (*oauth2.Config, error) {
|
|
endpoint, err := a.provider.Endpoint()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &oauth2.Config{
|
|
ClientID: a.clientID,
|
|
ClientSecret: a.clientSecret,
|
|
Endpoint: *endpoint,
|
|
Scopes: a.getScopes(),
|
|
RedirectURL: redirectURI,
|
|
}, nil
|
|
}
|
|
|
|
// generateAppState creates an app state nonce
|
|
func (a *ClientApp) generateAppState(returnURL string, pkceVerifier string, w http.ResponseWriter) (string, error) {
|
|
// According to the spec (https://www.rfc-editor.org/rfc/rfc6749#section-10.10), this must be guessable with
|
|
// probability <= 2^(-128). The following call generates one of 52^24 random strings, ~= 2^136 possibilities.
|
|
randStr, err := rand.String(24)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate app state: %w", err)
|
|
}
|
|
if returnURL == "" {
|
|
returnURL = a.baseHRef
|
|
}
|
|
cookieValue := fmt.Sprintf("%s\n%s\n%s", randStr, returnURL, pkceVerifier)
|
|
if encrypted, err := crypto.Encrypt([]byte(cookieValue), a.encryptionKey); err != nil {
|
|
return "", err
|
|
} else {
|
|
cookieValue = hex.EncodeToString(encrypted)
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: common.StateCookieName,
|
|
Value: cookieValue,
|
|
Expires: time.Now().Add(common.StateCookieMaxAge),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: a.secureCookie,
|
|
})
|
|
return randStr, nil
|
|
}
|
|
|
|
func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state string) (string, string, error) {
|
|
c, err := r.Cookie(common.StateCookieName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
val, err := hex.DecodeString(c.Value)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
val, err = crypto.Decrypt(val, a.encryptionKey)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
cookieVal := string(val)
|
|
redirectURL := a.baseHRef
|
|
pkceVerifier := ""
|
|
parts := strings.SplitN(cookieVal, "\n", 3)
|
|
if len(parts) > 1 && parts[1] != "" {
|
|
if !isValidRedirectURL(parts[1],
|
|
append([]string{a.settings.URL, a.baseHRef}, a.settings.AdditionalURLs...)) {
|
|
sanitizedURL := parts[1]
|
|
if len(sanitizedURL) > 100 {
|
|
sanitizedURL = sanitizedURL[:100]
|
|
}
|
|
log.Warnf("Failed to verify app state - got invalid redirectURL %q", sanitizedURL)
|
|
return "", "", fmt.Errorf("failed to verify app state: %w", ErrInvalidRedirectURL)
|
|
}
|
|
redirectURL = parts[1]
|
|
}
|
|
if len(parts) > 2 {
|
|
pkceVerifier = parts[2]
|
|
}
|
|
if parts[0] != state {
|
|
return "", "", fmt.Errorf("invalid state in '%s' cookie", common.AuthCookieName)
|
|
}
|
|
// set empty cookie to clear it
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: common.StateCookieName,
|
|
Value: "",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: a.secureCookie,
|
|
})
|
|
return redirectURL, pkceVerifier, nil
|
|
}
|
|
|
|
// isValidRedirectURL checks whether the given redirectURL matches on of the
|
|
// allowed URLs to redirect to.
|
|
//
|
|
// In order to be considered valid,the protocol and host (including port) have
|
|
// to match and if allowed path is not "/", redirectURL's path must be within
|
|
// allowed URL's path.
|
|
func isValidRedirectURL(redirectURL string, allowedURLs []string) bool {
|
|
if redirectURL == "" {
|
|
return true
|
|
}
|
|
r, err := url.Parse(redirectURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// We consider empty path the same as "/" for redirect URL
|
|
if r.Path == "" {
|
|
r.Path = "/"
|
|
}
|
|
// Prevent CRLF in the redirectURL
|
|
if strings.ContainsAny(r.Path, "\r\n") {
|
|
return false
|
|
}
|
|
for _, baseURL := range allowedURLs {
|
|
b, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// We consider empty path the same as "/" for allowed URL
|
|
if b.Path == "" {
|
|
b.Path = "/"
|
|
}
|
|
// scheme and host are mandatory to match.
|
|
if b.Scheme == r.Scheme && b.Host == r.Host {
|
|
// If path of redirectURL and allowedURL match, redirectURL is allowed
|
|
// if b.Path == r.Path {
|
|
// return true
|
|
// }
|
|
// If path of redirectURL is within allowed URL's path, redirectURL is allowed
|
|
if strings.HasPrefix(path.Clean(r.Path), b.Path) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
// No match - redirect URL is not allowed
|
|
return false
|
|
}
|
|
|
|
// HandleLogin formulates the proper OAuth2 URL (auth code or implicit) and redirects the user to
|
|
// the IDp login & consent page
|
|
func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
|
oidcConf, err := a.provider.ParseConfig()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pkceVerifier := ""
|
|
var opts []oauth2.AuthCodeOption
|
|
if config := a.settings.OIDCConfig(); config != nil {
|
|
opts = AppendClaimsAuthenticationRequestParameter(opts, config.RequestedIDTokenClaims)
|
|
}
|
|
|
|
oauth2Config, err := a.getOauth2ConfigForRedirectURI(a.getRedirectURIForRequest(r))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
returnURL := r.FormValue("return_url")
|
|
// Check if return_url is valid, otherwise abort processing (see https://github.com/argoproj/argo-cd/pull/4780)
|
|
if !isValidRedirectURL(returnURL, append([]string{a.settings.URL}, a.settings.AdditionalURLs...)) {
|
|
http.Error(w, "Invalid redirect URL: the protocol and host (including port) must match and the path must be within allowed URLs if provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if a.domainHint != "" {
|
|
opts = append(opts, oauth2.SetAuthURLParam("domain_hint", a.domainHint))
|
|
}
|
|
if a.usePKCE {
|
|
pkceVerifier = oauth2.GenerateVerifier()
|
|
opts = append(opts, oauth2.S256ChallengeOption(pkceVerifier))
|
|
}
|
|
stateNonce, err := a.generateAppState(returnURL, pkceVerifier, w)
|
|
if err != nil {
|
|
log.Errorf("Failed to initiate login flow: %v", err)
|
|
http.Error(w, "Failed to initiate login flow", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
grantType := InferGrantType(oidcConf)
|
|
var url string
|
|
switch grantType {
|
|
case GrantTypeAuthorizationCode:
|
|
url = oauth2Config.AuthCodeURL(stateNonce, opts...)
|
|
case GrantTypeImplicit:
|
|
url, err = ImplicitFlowURL(oauth2Config, stateNonce, opts...)
|
|
if err != nil {
|
|
log.Errorf("Failed to initiate implicit login flow: %v", err)
|
|
http.Error(w, "Failed to initiate implicit login flow", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, fmt.Sprintf("Unsupported grant type: %v", grantType), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
log.Infof("Performing %s flow login: %s", grantType, url)
|
|
http.Redirect(w, r, url, http.StatusSeeOther)
|
|
}
|
|
|
|
// getFederatedServiceAccountToken returns the specified file's content, which is expected to be Federated Kubernetes service account token.
|
|
// Kubernetes is responsible for updating the file as service account tokens expire.
|
|
// Azure Workload Identity mutation webhook will set the environment variable AZURE_FEDERATED_TOKEN_FILE
|
|
// Content of this file will contain a federated token which can be used in assertion with Microsoft Entra Application.
|
|
func (a *azureApp) getFederatedServiceAccountToken(context.Context) (string, error) {
|
|
file, ok := os.LookupEnv("AZURE_FEDERATED_TOKEN_FILE")
|
|
if file == "" || !ok {
|
|
return "", errors.New("AZURE_FEDERATED_TOKEN_FILE env variable not found, make sure workload identity is enabled on the cluster")
|
|
}
|
|
|
|
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
|
return "", errors.New("AZURE_FEDERATED_TOKEN_FILE specified file does not exist")
|
|
}
|
|
|
|
a.mtx.RLock()
|
|
if a.expires.Before(time.Now()) {
|
|
// ensure only one goroutine at a time updates the assertion
|
|
a.mtx.RUnlock()
|
|
a.mtx.Lock()
|
|
defer a.mtx.Unlock()
|
|
// double check because another goroutine may have acquired the write lock first and done the update
|
|
if now := time.Now(); a.expires.Before(now) {
|
|
content, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
a.assertion = string(content)
|
|
// Kubernetes rotates service account tokens when they reach 80% of their total TTL. The shortest TTL
|
|
// is 1 hour. That implies the token we just read is valid for at least 12 minutes (20% of 1 hour),
|
|
// but we add some margin for safety.
|
|
a.expires = now.Add(10 * time.Minute)
|
|
}
|
|
} else {
|
|
defer a.mtx.RUnlock()
|
|
}
|
|
return a.assertion, nil
|
|
}
|
|
|
|
// HandleCallback is the callback handler for an OAuth2 login flow
|
|
func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|
oauth2Config, err := a.getOauth2ConfigForRedirectURI(a.getRedirectURIForRequest(r))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
log.Infof("Callback: %s", r.URL)
|
|
if errMsg := r.FormValue("error"); errMsg != "" {
|
|
errorDesc := r.FormValue("error_description")
|
|
http.Error(w, html.EscapeString(errMsg)+": "+html.EscapeString(errorDesc), http.StatusBadRequest)
|
|
return
|
|
}
|
|
code := r.FormValue("code")
|
|
state := r.FormValue("state")
|
|
if code == "" {
|
|
// If code was not given, it implies implicit flow
|
|
a.handleImplicitFlow(r, w, state)
|
|
return
|
|
}
|
|
returnURL, pkceVerifier, err := a.verifyAppState(r, w, state)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := gooidc.ClientContext(r.Context(), a.client)
|
|
options := []oauth2.AuthCodeOption{}
|
|
|
|
if a.useAzureWorkloadIdentity {
|
|
clientAssertion, err := a.azure.getFederatedServiceAccountToken(ctx)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to generate client assertion: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
options = []oauth2.AuthCodeOption{
|
|
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
|
|
oauth2.SetAuthURLParam("client_assertion", clientAssertion),
|
|
}
|
|
}
|
|
|
|
if a.usePKCE {
|
|
options = append(options, oauth2.VerifierOption(pkceVerifier))
|
|
}
|
|
|
|
token, err := oauth2Config.Exchange(ctx, code, options...)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Parse out id token
|
|
idTokenRAW, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
http.Error(w, "no id_token in token response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
idToken, err := a.provider.Verify(ctx, idTokenRAW, a.settings)
|
|
if err != nil {
|
|
log.Warnf("Failed to verify oidc token: %s", err)
|
|
http.Error(w, common.TokenVerificationError, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set cache
|
|
var claims jwt.MapClaims
|
|
err = idToken.Claims(&claims)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// save the accessToken in memory for later use
|
|
sub := jwtutil.StringField(claims, "sub")
|
|
|
|
// Invalidate cached groups from previous sessions so a fresh login picks up updated memberships
|
|
if err := a.clientCache.Delete(FormatUserInfoResponseCacheKey(sub)); err != nil {
|
|
log.Warnf("failed to invalidate UserInfo cache for %s: %v", sub, err)
|
|
}
|
|
if err := a.clientCache.Delete(FormatAzureGroupsOverageResponseCacheKey(sub)); err != nil {
|
|
log.Warnf("failed to invalidate Azure groups overage cache for %s: %v", sub, err)
|
|
}
|
|
|
|
err = a.SetValueInEncryptedCache(ctx, FormatAccessTokenCacheKey(sub), []byte(token.AccessToken), GetTokenExpiration(claims))
|
|
if err != nil {
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
log.Errorf("cannot cache encrypted accessToken: %v (claims=%s)", err, claimsJSON)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cache encrypted raw token for background refresh
|
|
oidcTokenCache := NewOidcTokenCache(a.getRedirectURIForRequest(r), token, time.Now())
|
|
oidcTokenCacheJSON, err := json.Marshal(oidcTokenCache)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sid := jwtutil.StringField(claims, "sid")
|
|
ttl := sessionRemainingTTL(oidcTokenCache.SessionStart, a.settings.UserSessionDuration)
|
|
err = a.SetValueInEncryptedCache(ctx, formatOidcTokenCacheKey(sub, sid), oidcTokenCacheJSON, ttl)
|
|
if err != nil {
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
log.Errorf("cannot cache encrypted oidc token: %v (claims=%s)", err, claimsJSON)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if idTokenRAW != "" {
|
|
err = httputil.SetTokenCookie(idTokenRAW, a.baseHRef, a.secureCookie, w)
|
|
if err != nil {
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
http.Error(w, fmt.Sprintf("claims=%s, err=%v", claimsJSON, err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
log.Infof("Web login successful. Claims: %s", claimsJSON)
|
|
if os.Getenv(common.EnvVarSSODebug) == "1" {
|
|
claimsJSON, _ := json.MarshalIndent(claims, "", " ")
|
|
renderToken(w, a.redirectURI, idTokenRAW, token.RefreshToken, claimsJSON)
|
|
} else {
|
|
http.Redirect(w, r, returnURL, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// GetValueFromEncryptedCache is a convenience method for retrieving a value from cache and decrypting it. If the cache
|
|
// does not contain a value for the given key, a nil value is returned. Return handling should check for error and then
|
|
// check for nil.
|
|
func (a *ClientApp) GetValueFromEncryptedCache(ctx context.Context, key string) (value []byte, err error) {
|
|
_, span := tracer.Start(ctx, "oidc.ClientApp.GetValueFromEncryptedCache")
|
|
defer span.End()
|
|
var encryptedValue []byte
|
|
span.AddEvent("start cache read")
|
|
err = a.clientCache.Get(key, &encryptedValue)
|
|
span.AddEvent("end cache read")
|
|
if err != nil {
|
|
if errors.Is(err, cache.ErrCacheMiss) {
|
|
span.SetAttributes(attribute.Bool("cache_hit", false))
|
|
// Return nil to signify a cache miss
|
|
return nil, nil
|
|
}
|
|
err = fmt.Errorf("failed to get encrypted value from cache: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
span.SetAttributes(attribute.Bool("cache_hit", true))
|
|
value, err = crypto.Decrypt(encryptedValue, a.encryptionKey)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to decrypt value from cache: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
return value, err
|
|
}
|
|
|
|
// SetValueFromEncyrptedCache is a convenience method for encrypting a value and storing it in the cache at a given key.
|
|
// Cache expiration is set based on input.
|
|
func (a *ClientApp) SetValueInEncryptedCache(ctx context.Context, key string, value []byte, expiration time.Duration) error {
|
|
_, span := tracer.Start(ctx, "oidc.ClientApp.SetValueInEncryptedCache")
|
|
defer span.End()
|
|
encryptedValue, err := crypto.Encrypt(value, a.encryptionKey)
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return err
|
|
}
|
|
span.SetAttributes(
|
|
attribute.String("key", key),
|
|
attribute.Int("value_length", len(value)),
|
|
)
|
|
span.AddEvent("start cache write")
|
|
err = a.clientCache.Set(&cache.Item{
|
|
Key: key,
|
|
Object: encryptedValue,
|
|
CacheActionOpts: cache.CacheActionOpts{
|
|
Expiration: expiration,
|
|
},
|
|
})
|
|
span.AddEvent("end cache write")
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ClientApp) CheckAndRefreshToken(ctx context.Context, groupClaims jwt.MapClaims, refreshTokenThreshold time.Duration) (string, error) {
|
|
var span trace.Span
|
|
ctx, span = tracer.Start(ctx, "oidc.ClientApp.CheckAndRefreshToken")
|
|
defer span.End()
|
|
iss := jwtutil.StringField(groupClaims, "iss")
|
|
sub := jwtutil.StringField(groupClaims, "sub")
|
|
sid := jwtutil.StringField(groupClaims, "sid")
|
|
span.SetAttributes(
|
|
attribute.String("iss", iss),
|
|
attribute.String("sub", sub),
|
|
attribute.String("sid", sid),
|
|
)
|
|
if GetTokenExpiration(groupClaims) < refreshTokenThreshold {
|
|
token, err := a.GetUpdatedOidcTokenFromCache(ctx, sub, sid)
|
|
if err != nil {
|
|
log.Errorf("Failed to get token from cache: %v", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return "", err
|
|
}
|
|
if token != nil {
|
|
idTokenRAW, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
err = errors.New("empty id_token")
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return "", err
|
|
}
|
|
return idTokenRAW, nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// GetUpdatedOidcTokenFromCache fetches a token from cache and refreshes it if under the threshold for expiration.
|
|
// The cached token will also be updated if it is refreshed. Returns latest token or an error if the process fails.
|
|
func (a *ClientApp) GetUpdatedOidcTokenFromCache(ctx context.Context, subject string, sessionId string) (*oauth2.Token, error) {
|
|
var span trace.Span
|
|
ctx, span = tracer.Start(ctx, "oidc.ClientApp.GetUpdatedOidcTokenFromCache")
|
|
defer span.End()
|
|
|
|
ctx = gooidc.ClientContext(ctx, a.client)
|
|
span.SetAttributes(
|
|
attribute.String("subject", subject),
|
|
attribute.String("sessionId", sessionId),
|
|
)
|
|
|
|
// Get oauth2 config
|
|
cacheKey := formatOidcTokenCacheKey(subject, sessionId)
|
|
oidcTokenCacheJSON, err := a.GetValueFromEncryptedCache(ctx, cacheKey)
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
if oidcTokenCacheJSON == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
oidcTokenCache, err := GetOidcTokenCacheFromJSON(oidcTokenCacheJSON)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to unmarshal cached oidc token: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
tokenSource, err := a.GetTokenSourceFromCache(ctx, oidcTokenCache)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to get token source from cached oidc token: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
span.AddEvent("starting tokenSource.Token()")
|
|
token, err := tokenSource.Token()
|
|
span.AddEvent("finished tokenSource.Token()")
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to refresh token from source: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
if token.AccessToken != oidcTokenCache.Token.AccessToken {
|
|
span.AddEvent("updating cache with latest token")
|
|
|
|
oidcTokenCache = NewOidcTokenCache(oidcTokenCache.RedirectURL, token, oidcTokenCache.SessionStart)
|
|
oidcTokenCacheJSON, err = json.Marshal(oidcTokenCache)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to marshal oidc oidcTokenCache refresher: %w", err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
ttl := sessionRemainingTTL(oidcTokenCache.SessionStart, a.settings.UserSessionDuration)
|
|
if ttl <= 0 {
|
|
log.Info("session exceeded UserSessionDuration, forcing re-authentication")
|
|
|
|
// Return nil to force re-authentication
|
|
return nil, nil
|
|
}
|
|
err = a.SetValueInEncryptedCache(ctx, cacheKey, oidcTokenCacheJSON, ttl)
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
var implicitFlowTmpl = template.Must(template.New("implicit.html").Parse(`<script>
|
|
var hash = window.location.hash.substr(1);
|
|
var result = hash.split('&').reduce(function (result, item) {
|
|
var parts = item.split('=');
|
|
result[parts[0]] = parts[1];
|
|
return result;
|
|
}, {});
|
|
var idToken = result['id_token'];
|
|
var state = result['state'];
|
|
var returnURL = "{{ .ReturnURL }}";
|
|
if (state != "" && returnURL == "") {
|
|
window.location.href = window.location.href.split("#")[0] + "?state=" + result['state'] + window.location.hash;
|
|
} else if (returnURL != "") {
|
|
document.cookie = "{{ .CookieName }}=" + idToken + "; path=/";
|
|
window.location.href = returnURL;
|
|
}
|
|
</script>`))
|
|
|
|
// handleImplicitFlow completes an implicit OAuth2 flow. The id_token and state will be contained
|
|
// in the URL fragment. The javascript client first redirects to the callback URL, supplying the
|
|
// state nonce for verification, as well as looking up the return URL. Once verified, the client
|
|
// stores the id_token from the fragment as a cookie. Finally it performs the final redirect back to
|
|
// the return URL.
|
|
func (a *ClientApp) handleImplicitFlow(r *http.Request, w http.ResponseWriter, state string) {
|
|
type implicitFlowValues struct {
|
|
CookieName string
|
|
ReturnURL string
|
|
}
|
|
vals := implicitFlowValues{
|
|
CookieName: common.AuthCookieName,
|
|
}
|
|
if state != "" {
|
|
// Not using pkceVerifier, since PKCE is not supported in implicit flow.
|
|
returnURL, _, err := a.verifyAppState(r, w, state)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
vals.ReturnURL = returnURL
|
|
}
|
|
renderTemplate(w, implicitFlowTmpl, vals)
|
|
}
|
|
|
|
// ImplicitFlowURL is an adaptation of oauth2.Config::AuthCodeURL() which returns a URL
|
|
// appropriate for an OAuth2 implicit login flow (as opposed to authorization code flow).
|
|
func ImplicitFlowURL(c *oauth2.Config, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
|
opts = append(opts, oauth2.SetAuthURLParam("response_type", "id_token"))
|
|
randString, err := rand.String(24)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate nonce for implicit flow URL: %w", err)
|
|
}
|
|
opts = append(opts, oauth2.SetAuthURLParam("nonce", randString))
|
|
return c.AuthCodeURL(state, opts...), nil
|
|
}
|
|
|
|
// OfflineAccess returns whether or not 'offline_access' is a supported scope
|
|
func OfflineAccess(scopes []string) bool {
|
|
if len(scopes) == 0 {
|
|
// scopes_supported is a "RECOMMENDED" discovery claim, not a required
|
|
// one. If missing, assume that the provider follows the spec and has
|
|
// an "offline_access" scope.
|
|
return true
|
|
}
|
|
// See if scopes_supported has the "offline_access" scope.
|
|
return slices.Contains(scopes, gooidc.ScopeOfflineAccess)
|
|
}
|
|
|
|
// InferGrantType infers the proper grant flow depending on the OAuth2 client config and OIDC configuration.
|
|
// Returns either: "authorization_code" or "implicit"
|
|
func InferGrantType(oidcConf *OIDCConfiguration) string {
|
|
// Check the supported response types. If the list contains the response type 'code',
|
|
// then grant type is 'authorization_code'. This is preferred over the implicit
|
|
// grant type since refresh tokens cannot be issued that way.
|
|
if slices.Contains(oidcConf.ResponseTypesSupported, ResponseTypeCode) {
|
|
return GrantTypeAuthorizationCode
|
|
}
|
|
|
|
// Assume implicit otherwise
|
|
return GrantTypeImplicit
|
|
}
|
|
|
|
// AppendClaimsAuthenticationRequestParameter appends a OIDC claims authentication request parameter
|
|
// to `opts` with the `requestedClaims`
|
|
func AppendClaimsAuthenticationRequestParameter(opts []oauth2.AuthCodeOption, requestedClaims map[string]*oidc.Claim) []oauth2.AuthCodeOption {
|
|
if len(requestedClaims) == 0 {
|
|
return opts
|
|
}
|
|
log.Infof("RequestedClaims: %s\n", requestedClaims)
|
|
claimsRequestParameter, err := createClaimsAuthenticationRequestParameter(requestedClaims)
|
|
if err != nil {
|
|
log.Errorf("Failed to create OIDC claims authentication request parameter from config: %s", err)
|
|
return opts
|
|
}
|
|
return append(opts, claimsRequestParameter)
|
|
}
|
|
|
|
func createClaimsAuthenticationRequestParameter(requestedClaims map[string]*oidc.Claim) (oauth2.AuthCodeOption, error) {
|
|
claimsRequest := ClaimsRequest{IDToken: requestedClaims}
|
|
claimsRequestRAW, err := json.Marshal(claimsRequest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return oauth2.SetAuthURLParam("claims", string(claimsRequestRAW)), nil
|
|
}
|
|
|
|
// SetGroupsClaimFromEndpoint takes a claims object and adds groups claim from either:
|
|
// - the UserInfo endpoint if enabled
|
|
// - the Microsoft Graph API if Azure groups overage claim is detected and enabled
|
|
// This is required by some SSO implementations as they don't provide the groups claim in the ID token.
|
|
// If querying the endpoint fails, we return an error to indicate the session is invalid.
|
|
// We assume that everywhere in argocd jwt.MapClaims is used as type for interface jwt.Claims
|
|
// otherwise this would cause a panic.
|
|
func (a *ClientApp) SetGroupsClaimFromEndpoint(ctx context.Context, claims jwt.Claims, sessionManagerClaimsIssuer string) (jwt.MapClaims, error) {
|
|
var groupClaims jwt.MapClaims
|
|
var ok bool
|
|
if groupClaims, ok = claims.(jwt.MapClaims); !ok {
|
|
if tmpClaims, ok := claims.(*jwt.MapClaims); ok {
|
|
if tmpClaims != nil {
|
|
groupClaims = *tmpClaims
|
|
}
|
|
}
|
|
}
|
|
|
|
iss := jwtutil.StringField(groupClaims, "iss")
|
|
if iss != sessionManagerClaimsIssuer {
|
|
// Path 1: UserInfo endpoint — mutually exclusive with Path 2 (Azure overage).
|
|
// For Entra ID, UserInfo returns the same data as the ID token, so both are never needed.
|
|
if a.settings.UserInfoGroupsEnabled() && a.settings.UserInfoPath() != "" {
|
|
userInfo, unauthorized, err := a.GetUserInfo(ctx, groupClaims, a.settings.IssuerURL(), a.settings.UserInfoBaseURL(), a.settings.UserInfoPath())
|
|
if unauthorized {
|
|
return groupClaims, fmt.Errorf("error while querying userinfo endpoint: %w", err)
|
|
}
|
|
if err != nil {
|
|
return groupClaims, fmt.Errorf("error fetching user info endpoint: %w", err)
|
|
}
|
|
if groupClaims["sub"] != userInfo["sub"] {
|
|
return groupClaims, errors.New("subject of claims from user info endpoint didn't match subject of idToken, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo")
|
|
}
|
|
groupClaims["groups"] = userInfo["groups"]
|
|
return groupClaims, nil
|
|
}
|
|
|
|
// Path 2: Azure AD groups overage claim resolution via Microsoft Graph API
|
|
if a.settings.AzureUserGroupOverageClaimEnabled() && a.settings.AzureGraphAPIEndpoint() != "" {
|
|
groups, err := a.GetUserGroupsFromAzureOverageClaim(ctx, groupClaims, a.settings.AzureGraphAPIEndpoint())
|
|
if err != nil {
|
|
return groupClaims, fmt.Errorf("error fetching groups from Azure overage claim: %w", err)
|
|
}
|
|
if len(groups) > 0 {
|
|
groupClaims["groups"] = groups
|
|
}
|
|
return groupClaims, nil
|
|
}
|
|
}
|
|
|
|
return groupClaims, nil
|
|
}
|
|
|
|
// GetUserInfo queries the IDP userinfo endpoint for claims
|
|
func (a *ClientApp) GetUserInfo(ctx context.Context, actualClaims jwt.MapClaims, issuerURL, userInfoBaseURL, userInfoPath string) (jwt.MapClaims, bool, error) {
|
|
var span trace.Span
|
|
ctx, span = tracer.Start(ctx, "oidc.ClientApp.GetUserInfo")
|
|
defer span.End()
|
|
sub := jwtutil.StringField(actualClaims, "sub")
|
|
var claims jwt.MapClaims
|
|
var encClaims []byte
|
|
|
|
// in case we got it in the cache, we just return the item
|
|
clientCacheKey := FormatUserInfoResponseCacheKey(sub)
|
|
if err := a.clientCache.Get(clientCacheKey, &encClaims); err == nil {
|
|
claimsRaw, err := crypto.Decrypt(encClaims, a.encryptionKey)
|
|
if err != nil {
|
|
log.Errorf("decrypting the cached claims failed (sub=%s): %s", sub, err)
|
|
} else {
|
|
err = json.Unmarshal(claimsRaw, &claims)
|
|
if err == nil {
|
|
// return the cached claims since they are not yet expired, were successfully decrypted and unmarshaled
|
|
return claims, false, nil
|
|
}
|
|
log.Errorf("cannot unmarshal cached claims structure: %s", err)
|
|
}
|
|
}
|
|
|
|
// check if the accessToken for the user is still present
|
|
accessTokenBytes, err := a.GetValueFromEncryptedCache(ctx, FormatAccessTokenCacheKey(sub))
|
|
if err != nil {
|
|
return claims, true, fmt.Errorf("could not read accessToken from cache for %s: %w", sub, err)
|
|
}
|
|
|
|
if accessTokenBytes == nil {
|
|
return claims, true, fmt.Errorf("no accessToken for %s: %w", sub, err)
|
|
}
|
|
|
|
var url string
|
|
if userInfoBaseURL != "" {
|
|
url = userInfoBaseURL + userInfoPath
|
|
} else {
|
|
url = issuerURL + userInfoPath
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
err = fmt.Errorf("failed creating new http request: %w", err)
|
|
return claims, false, err
|
|
}
|
|
|
|
bearer := "Bearer " + string(accessTokenBytes)
|
|
request.Header.Set("Authorization", bearer)
|
|
|
|
response, err := a.client.Do(request)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("failed to query userinfo endpoint of IDP: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode == http.StatusUnauthorized {
|
|
return claims, true, err
|
|
}
|
|
|
|
// according to https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponseValidation
|
|
// the response should be validated
|
|
header := response.Header.Get("content-type")
|
|
rawBody, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("got error reading response body: %w", err)
|
|
}
|
|
switch header {
|
|
case "application/jwt":
|
|
// if body is JWT, first validate it before extracting claims
|
|
idToken, err := a.provider.Verify(ctx, string(rawBody), a.settings)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("user info response in jwt format not valid: %w", err)
|
|
}
|
|
err = idToken.Claims(claims)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("cannot get claims from userinfo jwt: %w", err)
|
|
}
|
|
default:
|
|
// if body is json, unsigned and unencrypted claims can be deserialized
|
|
err = json.Unmarshal(rawBody, &claims)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("failed to decode response body to struct: %w", err)
|
|
}
|
|
}
|
|
|
|
// in case response was successfully validated and there was no error, put item in cache
|
|
// but first let's determine the expiry of the cache
|
|
var cacheExpiry time.Duration
|
|
settingExpiry := a.settings.UserInfoCacheExpiration()
|
|
tokenExpiry := GetTokenExpiration(claims)
|
|
|
|
// only use configured expiry if the token lives longer and the expiry is configured
|
|
// if the token has no expiry, use the expiry of the actual token
|
|
// otherwise use the expiry of the token
|
|
switch {
|
|
case settingExpiry < tokenExpiry && settingExpiry != 0:
|
|
cacheExpiry = settingExpiry
|
|
case tokenExpiry < 0:
|
|
cacheExpiry = GetTokenExpiration(actualClaims)
|
|
default:
|
|
cacheExpiry = tokenExpiry
|
|
}
|
|
|
|
rawClaims, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("could not marshal claim to json: %w", err)
|
|
}
|
|
encClaims, err = crypto.Encrypt(rawClaims, a.encryptionKey)
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("could not encrypt user info response: %w", err)
|
|
}
|
|
|
|
err = a.clientCache.Set(&cache.Item{
|
|
Key: clientCacheKey,
|
|
Object: encClaims,
|
|
CacheActionOpts: cache.CacheActionOpts{
|
|
Expiration: cacheExpiry,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return claims, false, fmt.Errorf("could not put item to cache: %w", err)
|
|
}
|
|
|
|
return claims, false, nil
|
|
}
|
|
|
|
// GetTokenExpiration returns a time.Duration until the token expires
|
|
func GetTokenExpiration(claims jwt.MapClaims) time.Duration {
|
|
// get duration until token expires
|
|
exp := jwtutil.Float64Field(claims, "exp")
|
|
tm := time.Unix(int64(exp), 0)
|
|
tokenExpiry := time.Until(tm)
|
|
return tokenExpiry
|
|
}
|
|
|
|
// getScopes returns scopes based on provider configuration
|
|
func (a *ClientApp) getScopes() []string {
|
|
scopes := make([]string, 0)
|
|
if config := a.settings.OIDCConfig(); config != nil {
|
|
scopes = GetScopesOrDefault(config.RequestedScopes)
|
|
} else if a.settings.IsDexConfigured() {
|
|
scopes = append(GetScopesOrDefault(nil), common.DexFederatedScope)
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
// FormatUserInfoResponseCacheKey returns the key which is used to store userinfo of user in cache
|
|
func FormatUserInfoResponseCacheKey(sub string) string {
|
|
return fmt.Sprintf("%s_%s", UserInfoResponseCachePrefix, sub)
|
|
}
|
|
|
|
// FormatAccessTokenCacheKey returns the key which is used to store the accessToken of a user in cache
|
|
func FormatAccessTokenCacheKey(sub string) string {
|
|
return fmt.Sprintf("%s_%s", AccessTokenCachePrefix, sub)
|
|
}
|
|
|
|
// formatRefreshTokenCacheKey returns the key which is used to store the oidc Token for a session in cache
|
|
func formatOidcTokenCacheKey(sub string, sid string) string {
|
|
return fmt.Sprintf("%s_%s_%s", OidcTokenCachePrefix, sub, sid)
|
|
}
|
|
|
|
func (a *ClientApp) IssuerURL() string {
|
|
return a.issuerURL
|
|
}
|