Files
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

998 lines
35 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"bytes"
"cmp"
"context"
"encoding/json"
"maps"
"net/http"
"net/url"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
"github.com/ory/herodot"
"github.com/ory/kratos/cipher"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
oidcv1 "github.com/ory/kratos/gen/oidc/v1"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/selfservice/strategy"
"github.com/ory/kratos/selfservice/strategy/oidc/oidcerr"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/clock"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/reqlog"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
const (
RouteBase = "/self-service/methods/oidc"
RouteAuth = RouteBase + "/auth/{flow}"
RouteCallback = RouteBase + "/callback/{provider}"
RouteCallbackGeneric = RouteBase + "/callback"
RouteOrganizationCallback = RouteBase + "/organization/{organization}/callback/{provider}"
)
var (
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
_ x.Handler = (*Strategy)(nil)
)
type Dependencies interface {
clock.Provider
errorx.ManagementProvider
config.Provider
logrusx.Provider
x.CookieProvider
nosurfx.CSRFProvider
nosurfx.CSRFTokenGeneratorProvider
httpx.WriterProvider
httpx.ClientProvider
otelx.Provider
identity.ValidationProvider
identity.PrivilegedPoolProvider
identity.ActiveCredentialsCounterStrategyProvider
identity.ManagementProvider
session.ManagementProvider
session.HandlerProvider
sessiontokenexchange.PersistenceProvider
login.HookExecutorProvider
login.FlowPersistenceProvider
login.HooksProvider
login.StrategyProvider
login.HandlerProvider
login.ErrorHandlerProvider
registration.HookExecutorProvider
registration.FlowPersistenceProvider
registration.HooksProvider
registration.StrategyProvider
registration.HandlerProvider
registration.ErrorHandlerProvider
settings.ErrorHandlerProvider
settings.FlowPersistenceProvider
settings.HookExecutorProvider
continuity.ManagementProvider
cipher.Provider
jsonnetsecure.VMProvider
}
func isForced(req interface{}) bool {
f, ok := req.(interface {
IsRefresh() bool
})
return ok && f.IsRefresh()
}
// ConflictingIdentityVerdict encodes the decision on what to do on a oconflict
// between an existing and a new identity.
type ConflictingIdentityVerdict int
const (
// ConflictingIdentityVerdictUnknown is the default value and should not be used.
ConflictingIdentityVerdictUnknown ConflictingIdentityVerdict = iota
// ConflictingIdentityVerdictReject rejects the new identity. The flow will
// continue with an explicit account linking step, where the user will need to
// confirm an existing credential on the identity.
ConflictingIdentityVerdictReject
// ConflictingIdentityVerdictMerge merges the new identity into the existing.
ConflictingIdentityVerdictMerge
)
// Strategy implements selfservice.LoginStrategy, selfservice.RegistrationStrategy and selfservice.SettingsStrategy.
// It supports login, registration and settings via OpenID Providers.
type Strategy struct {
d Dependencies
validator *schema.Validator
credType identity.CredentialsType
handleUnknownProviderError func(err error) error
handleMethodNotAllowedError func(err error) error
conflictingIdentityPolicy ConflictingIdentityPolicy
}
type ConflictingIdentityPolicy func(ctx context.Context, existingIdentity, newIdentity *identity.Identity, provider Provider, claims *Claims) ConflictingIdentityVerdict
type AuthCodeContainer struct {
FlowID string `json:"flow_id"`
State string `json:"state"`
IdentitySchema flow.IdentitySchema `json:"identity_schema_id,omitempty"`
Traits json.RawMessage `json:"traits"`
TransientPayload json.RawMessage `json:"transient_payload"`
}
func (s *Strategy) CountActiveFirstFactorCredentials(ctx context.Context, cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return CountActiveFirstFactorCredentials(ctx, s.ID(), cc, false)
}
func CountActiveFirstFactorCredentials(_ context.Context, id identity.CredentialsType, cc map[identity.CredentialsType]identity.Credentials, withOrgs bool) (count int, err error) {
for _, c := range cc {
if c.Type == id && gjson.ValidBytes(c.Config) {
var conf identity.CredentialsOIDC
if err = json.Unmarshal(c.Config, &conf); err != nil {
return 0, errors.WithStack(err)
}
for _, identifier := range c.Identifiers {
provider, sub, ok := strings.Cut(identifier, ":")
if !ok {
continue
}
for _, prov := range conf.Providers {
if withOrgs && len(prov.Organization) == 0 {
continue
} else if !withOrgs && len(prov.Organization) > 0 {
continue
}
if provider == prov.Provider && sub == prov.Subject && prov.Subject != "" && prov.Provider != "" {
count++
}
}
}
}
}
return
}
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return 0, nil
}
func (s *Strategy) RegisterPublicRoutes(r *httprouterx.RouterPublic) {
wrappedHandleCallback := strategy.IsDisabled(s.d, s.ID().String(), s.HandleCallback)
r.GET(RouteCallback, wrappedHandleCallback)
r.GET(RouteCallbackGeneric, wrappedHandleCallback)
// Apple can use the POST request method when calling the callback
// Apple is the only (known) provider that sometimes does a form POST to the callback URL.
// This is a workaround to handle this case.
// But since the URL contains the `id` of the provider, we just allow all OIDC provider callbacks to bypass CSRF.
// This is fine, because all other providers seem to use GET, which is CSRF safe.
s.d.CSRFHandler().IgnoreGlob(RouteBase + "/callback/*")
// When handler is called using POST method, the cookies are not attached to the request
// by the browser. So here we just redirect the request to the same location rewriting the
// form fields to query params. This second GET request should have the cookies attached.
r.POST(RouteCallback, s.redirectToGET)
}
func (s *Strategy) RegisterAdminRoutes(*httprouterx.RouterAdmin) {}
// Redirect POST request to GET rewriting form fields to query params.
func (s *Strategy) redirectToGET(w http.ResponseWriter, r *http.Request) {
publicURL := s.d.Config().SelfPublicURL(r.Context())
dest := *r.URL
dest.Host = publicURL.Host
dest.Scheme = publicURL.Scheme
if err := r.ParseForm(); err == nil {
q := dest.Query()
for key, values := range r.Form {
for _, value := range values {
q.Set(key, value)
}
}
dest.RawQuery = q.Encode()
}
dest.Path = filepath.Join(publicURL.Path, dest.Path)
http.Redirect(w, r, dest.String(), http.StatusFound)
}
type NewStrategyOpt func(s *Strategy)
// ForCredentialType overrides the credentials type for this strategy.
func ForCredentialType(ct identity.CredentialsType) NewStrategyOpt {
return func(s *Strategy) { s.credType = ct }
}
// WithUnknownProviderHandler overrides the error returned when the provider
// cannot be found.
func WithUnknownProviderHandler(handler func(error) error) NewStrategyOpt {
return func(s *Strategy) { s.handleUnknownProviderError = handler }
}
// WithHandleMethodNotAllowedError overrides the error returned when method is
// not allowed.
func WithHandleMethodNotAllowedError(handler func(error) error) NewStrategyOpt {
return func(s *Strategy) { s.handleMethodNotAllowedError = handler }
}
// WithOnConflictingIdentity sets a policy handler for deciding what to do when a
// new identity conflicts with an existing one during login.
func WithOnConflictingIdentity(handler ConflictingIdentityPolicy) NewStrategyOpt {
return func(s *Strategy) { s.conflictingIdentityPolicy = handler }
}
// SetOnConflictingIdentity sets a policy handler for deciding what to do when a
// new identity conflicts with an existing one during login. This should only be
// called in tests.
func (s *Strategy) SetOnConflictingIdentity(t testing.TB, handler ConflictingIdentityPolicy) {
if t == nil {
panic("this should only be called in tests")
}
s.conflictingIdentityPolicy = handler
}
func NewStrategy(d Dependencies, opts ...NewStrategyOpt) *Strategy {
s := &Strategy{
d: d,
validator: schema.NewValidator(),
credType: identity.CredentialsTypeOIDC,
handleUnknownProviderError: func(err error) error { return err },
handleMethodNotAllowedError: func(err error) error { return err },
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Strategy) ID() identity.CredentialsType {
return s.credType
}
func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.UUID, kind oidcv1.FlowKind) (f flow.Flow, err error) {
if rid.IsNil() {
return nil, errors.WithStack(herodot.ErrBadRequest().WithReason("The session cookie contains invalid values and the flow could not be executed. Please try again."))
}
switch kind {
case oidcv1.FlowKind_FLOW_KIND_LOGIN:
lf, err := s.d.LoginFlowPersister().GetLoginFlow(ctx, rid)
if err != nil {
return nil, err
}
return lf, lf.Valid(s.d.Clock())
case oidcv1.FlowKind_FLOW_KIND_REGISTRATION:
rf, err := s.d.RegistrationFlowPersister().GetRegistrationFlow(ctx, rid)
if err != nil {
return nil, err
}
return rf, rf.Valid(s.d.Clock())
case oidcv1.FlowKind_FLOW_KIND_SETTINGS:
sf, err := s.d.SettingsFlowPersister().GetSettingsFlow(ctx, rid)
if err != nil {
return nil, err
}
// The flow validation only reads the session's identity ID, so skip loading the
// devices and the identity's associations.
sess, err := s.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandDefault, identity.ExpandNothing)
if err != nil {
return sf, err
}
return sf, sf.Valid(s.d.Clock(), sess)
}
// fallback to the old behavior for backwards compatibility
for _, kind := range []oidcv1.FlowKind{oidcv1.FlowKind_FLOW_KIND_LOGIN, oidcv1.FlowKind_FLOW_KIND_REGISTRATION, oidcv1.FlowKind_FLOW_KIND_SETTINGS} {
if f, err = s.validateFlow(ctx, r, rid, kind); f != nil {
return f, err
}
}
return f, err
}
func (s *Strategy) ValidateCallback(w http.ResponseWriter, r *http.Request) (flow.Flow, *oidcv1.State, *AuthCodeContainer, error) {
var (
codeParam = cmp.Or(r.URL.Query().Get("code"), r.URL.Query().Get("authCode"))
stateParam = r.URL.Query().Get("state")
errorParam = r.URL.Query().Get("error")
)
if stateParam == "" {
return nil, nil, nil, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the state query parameter.`)))
}
state, err := DecryptState(r.Context(), s.d.Cipher(r.Context()), stateParam)
if err != nil {
return nil, nil, nil, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the state parameter is invalid.`)))
}
if providerFromURL := r.PathValue("provider"); providerFromURL != "" {
// We're serving an OIDC callback URL with provider in the URL.
if state.ProviderId == "" {
// provider in URL, but not in state: compatiblity mode, remove this fallback later
state.ProviderId = providerFromURL
} else if state.ProviderId != providerFromURL {
// provider in state, but URL with different provider -> something's fishy
return nil, nil, nil, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow: provider mismatch between internal state and URL.`)))
}
}
if state.ProviderId == "" {
// weird: provider neither in the state nor in the URL
return nil, nil, nil, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow: provider could not be retrieved from state nor URL.`)))
}
f, err := s.validateFlow(r.Context(), r, uuid.FromBytesOrNil(state.FlowId), state.FlowKind)
if err != nil {
return nil, state, nil, err
}
tokenCode, hasSessionTokenCode, err := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.GetID())
if err != nil {
return nil, state, nil, err
}
cntnr := AuthCodeContainer{}
var refStore continuity.ContainerReferenceStore
if f.GetType() == flow.TypeBrowser || !hasSessionTokenCode {
refStore = continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(r.Context()))
} else {
if !codeMatches(state, tokenCode.InitCode) {
return nil, state, &cntnr, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the code.`)))
}
if ic, ok := f.(flow.InternalContexter); ok {
refStore = continuity.NewInternalContextReferenceStore(ic)
} else {
// This should never happen, because all flows implement the InternalContexter interface, but we need to handle this case anyway to avoid panics.
return nil, state, &cntnr, oidcerr.Wrap(oidcerr.StepCallback, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Native flow does not support InternalContext.")))
}
}
if _, err := s.d.ContinuityManager().Continue(r.Context(), w, r, sessionName, refStore,
continuity.WithPayload(&cntnr),
continuity.WithExpireInsteadOfDelete(time.Minute),
); err != nil {
return nil, state, nil, err
}
if f.GetType() == flow.TypeBrowser || !hasSessionTokenCode {
if stateParam != cntnr.State {
return nil, state, &cntnr, oidcerr.Wrap(oidcerr.StepStateValidate, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`)))
}
} else {
cntnr.State = stateParam
cntnr.FlowID = uuid.FromBytesOrNil(state.FlowId).String()
}
if errorParam != "" {
return f, state, &cntnr, oidcerr.Wrap(oidcerr.StepProviderDenied, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider returned error "%s": %s`, r.URL.Query().Get("error"), r.URL.Query().Get("error_description"))))
}
if codeParam == "" {
return f, state, &cntnr, oidcerr.Wrap(oidcerr.StepCallback, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the code query parameter.`)))
}
return f, state, &cntnr, nil
}
func registrationOrLoginFlowID(flow any) (uuid.UUID, bool) {
switch f := flow.(type) {
case *registration.Flow:
return f.ID, true
case *login.Flow:
return f.ID, true
default:
return uuid.Nil, false
}
}
func (s *Strategy) alreadyAuthenticated(ctx context.Context, w http.ResponseWriter, r *http.Request, f interface{}) (bool, error) {
// Only the session ID is read below, so skip loading the devices and the identity.
if sess, _ := s.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandNothing, identity.ExpandNothing); sess != nil {
if _, ok := f.(*settings.Flow); ok {
// ignore this if it's a settings flow
} else if !isForced(f) {
returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx)
if redirecter, ok := f.(flow.FlowWithRedirect); ok {
r, err := redir.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.d)...)
if err == nil {
returnTo = r
}
}
if flowID, ok := registrationOrLoginFlowID(f); ok {
if codes, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(ctx, flowID); hasCode {
if err := s.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, flowID, sess.ID); err != nil {
return false, err
}
q := returnTo.Query()
q.Set("code", codes.ReturnToCode)
returnTo.RawQuery = q.Encode()
}
}
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
return true, nil
}
}
return false, nil
}
func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) {
var (
code = cmp.Or(r.URL.Query().Get("code"), r.URL.Query().Get("authCode"))
err error
)
ctx := r.Context()
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "strategy.oidc.HandleCallback")
defer otelx.End(span, &err)
r = r.WithContext(ctx)
req, state, cntnr, err := s.ValidateCallback(w, r)
if err != nil {
if req != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err))
} else {
s.d.SelfServiceErrorManager().Forward(ctx, w, r, s.HandleError(ctx, w, r, nil, "", nil, err))
}
return
}
if authenticated, err := s.alreadyAuthenticated(ctx, w, r, req); err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err))
} else if authenticated {
return
}
provider, err := s.Provider(ctx, state.ProviderId)
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err))
return
}
var claims *Claims
var et *identity.CredentialsOIDCEncryptedTokens
switch p := provider.(type) {
case OAuth2Provider:
t0 := time.Now()
token, err := s.exchangeCode(ctx, p, code, PKCEVerifier(state))
reqlog.AccumulateExternalLatency(ctx, time.Since(t0))
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepTokenExchange, err)))
return
}
et, err = s.encryptOAuth2Tokens(ctx, token)
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepCallback, err)))
return
}
t0 = time.Now()
claims, err = p.Claims(ctx, token, r.URL.Query())
reqlog.AccumulateExternalLatency(ctx, time.Since(t0))
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepClaimsDecode, err)))
return
}
case OAuth1Provider:
t0 := time.Now()
token, err := p.ExchangeToken(ctx, r)
reqlog.AccumulateExternalLatency(ctx, time.Since(t0))
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepTokenExchange, err)))
return
}
t0 = time.Now()
claims, err = p.Claims(ctx, token)
reqlog.AccumulateExternalLatency(ctx, time.Since(t0))
if err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepClaimsDecode, err)))
return
}
}
if err = claims.Validate(); err != nil {
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, oidcerr.Wrap(oidcerr.StepClaimsDecode, err)))
return
}
span.SetAttributes(attribute.StringSlice("claims", slices.Collect(maps.Keys(claims.RawClaims))))
switch a := req.(type) {
case *login.Flow:
a.Active = s.ID()
a.TransientPayload = cntnr.TransientPayload
a.IdentitySchema = cntnr.IdentitySchema
// Test login flows short-circuit here: capture debug payload, do not
// touch identity or session state, do not run ProcessLogin.
if a.IsTest() {
if err := s.processTestLogin(ctx, w, r, a, claims, provider); err != nil {
s.forwardError(ctx, w, r, a, err)
}
return
}
if ff, err := s.ProcessLogin(ctx, w, r, a, et, claims, provider, cntnr); err != nil {
if errors.Is(err, flow.ErrCompletedByStrategy) {
return
}
if ff != nil {
s.forwardError(ctx, w, r, ff, err)
return
}
s.forwardError(ctx, w, r, a, err)
}
return
case *registration.Flow:
a.Active = s.ID()
a.TransientPayload = cntnr.TransientPayload
a.IdentitySchema = cntnr.IdentitySchema
if ff, err := s.processRegistration(ctx, w, r, a, et, claims, provider, cntnr); errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
if ff != nil {
s.forwardError(ctx, w, r, ff, err)
return
}
s.forwardError(ctx, w, r, a, err)
}
return
case *settings.Flow:
a.Active = sqlxx.NullString(s.ID())
a.TransientPayload = cntnr.TransientPayload
// The full expansion is required: the settings flow update reads the session's identity.
sess, err := s.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandEverything, identity.ExpandEverything)
if err != nil {
s.forwardError(ctx, w, r, a, s.HandleError(ctx, w, r, a, state.ProviderId, nil, err))
return
}
if err := s.linkProvider(ctx, w, r, &settings.UpdateContext{Session: sess, Flow: a}, et, claims, provider); err != nil {
s.forwardError(ctx, w, r, a, s.HandleError(ctx, w, r, a, state.ProviderId, nil, err))
return
}
return
default:
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, errors.WithStack(x.PseudoPanic.
WithDetailf("cause", "Unexpected type in OpenID Connect flow: %T", a))))
return
}
}
func (s *Strategy) exchangeCode(ctx context.Context, provider OAuth2Provider, code string, opts []oauth2.AuthCodeOption) (token *oauth2.Token, err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "strategy.oidc.exchangeCode", trace.WithAttributes(
attribute.String("provider_id", provider.Config().ID),
attribute.String("provider_label", provider.Config().Label)))
defer otelx.End(span, &err)
te, ok := provider.(OAuth2TokenExchanger)
if !ok {
te, err = provider.OAuth2(ctx)
if err != nil {
return nil, err
}
}
client := s.d.HTTPClient(ctx)
ctx = context.WithValue(ctx, oauth2.HTTPClient, client.HTTPClient)
return te.Exchange(ctx, code, opts...)
}
func (s *Strategy) populateMethod(r *http.Request, f flow.Flow, message func(provider string, providerId string) *text.Message) error {
conf, err := s.Config(r.Context())
if err != nil {
return err
}
f.GetUI().SetCSRF(s.d.GenerateCSRFToken(r))
AddProviders(f.GetUI(), conf.Providers, message, s.ID())
return nil
}
func (s *Strategy) Config(ctx context.Context) (*ConfigurationCollection, error) {
var c ConfigurationCollection
conf := s.d.Config().SelfServiceStrategy(ctx, string(s.ID())).Config
if err := json.
NewDecoder(bytes.NewBuffer(conf)).
Decode(&c); err != nil {
s.d.Logger().WithError(err).WithField("config", conf)
return nil, errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Unable to decode OpenID Connect Provider configuration: %s", err))
}
return &c, nil
}
func (s *Strategy) Provider(ctx context.Context, id string) (Provider, error) {
if c, err := s.Config(ctx); err != nil {
return nil, err
} else if provider, err := c.Provider(id, s.d); err != nil {
return nil, s.handleUnknownProviderError(err)
} else {
return provider, nil
}
}
func (s *Strategy) forwardError(ctx context.Context, w http.ResponseWriter, r *http.Request, f flow.Flow, err error) {
switch ff := f.(type) {
case *login.Flow:
// Test login flows never surface an error page — the admin wants a
// DebugPayload populated with whatever was captured before the
// failure. Route to finishTestLoginWithError which redirects to the
// admin test UI with Error.Step + Error.Message set.
if ff.IsTest() {
if finishErr := s.finishTestLoginWithError(ctx, w, r, ff, err); finishErr != nil {
s.d.SelfServiceErrorManager().Forward(ctx, w, r, finishErr)
}
return
}
s.d.LoginFlowErrorHandler().WriteFlowError(w, r, ff, s.ID(), s.NodeGroup(), err)
case *registration.Flow:
s.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, ff, s.ID(), s.NodeGroup(), err)
case *settings.Flow:
var i *identity.Identity
var sess *session.Session
if currentSession, err := s.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandEverything, identity.ExpandEverything); err == nil {
i = currentSession.Identity
sess = currentSession
}
s.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, s.NodeGroup(), ff, i, sess, err)
default:
panic(errors.Errorf("unexpected type: %T", ff))
}
}
func (s *Strategy) HandleError(ctx context.Context, w http.ResponseWriter, r *http.Request, f flow.Flow, usedProviderID string, traits []byte, err error) error {
switch rf := f.(type) {
case *login.Flow:
return err
case *registration.Flow:
// Reset all nodes to not confuse users.
// This is kinda hacky and will probably need to be updated at some point.
if dup := new(identity.ErrDuplicateCredentials); errors.As(err, &dup) {
err = schema.NewDuplicateCredentialsError(dup)
if validationErr := new(schema.ValidationError); errors.As(err, &validationErr) {
for _, m := range validationErr.Messages {
m := m
rf.UI.Messages.Add(&m)
}
} else {
rf.UI.Messages.Add(text.NewErrorValidationDuplicateCredentialsOnOIDCLink())
}
lf, err := s.registrationToLogin(ctx, w, r, rf)
if err != nil {
return err
}
// return a new login flow with the error message embedded in the login flow.
var redirectURL *url.URL
if lf.Type == flow.TypeAPI {
returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx)
if redirecter, ok := f.(flow.FlowWithRedirect); ok {
secureReturnTo, err := redir.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.d)...)
if err == nil {
returnTo = secureReturnTo
}
}
redirectURL = lf.AppendTo(returnTo)
} else {
redirectURL = lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(ctx))
}
if dc, err := flow.DuplicateCredentials(lf); err == nil && dc != nil {
redirectURL = urlx.CopyWithQuery(redirectURL, url.Values{"no_org_ui": {"true"}})
s.populateAccountLinkingUI(ctx, lf, usedProviderID, dc.DuplicateIdentifier, dup.AvailableCredentials(), dup.AvailableOIDCProviders())
if err := s.d.LoginFlowPersister().UpdateLoginFlow(ctx, lf); err != nil {
return err
}
}
x.SendFlowErrorAsRedirectOrJSON(w, r, s.d.Writer(), lf, redirectURL.String())
// ensure the function does not continue to execute
return errors.WithStack(flow.ErrCompletedByStrategy)
}
rf.UI.Nodes = node.Nodes{}
// Adds the "Continue" button
rf.UI.SetCSRF(s.d.GenerateCSRFToken(r))
AddProvider(rf.UI, usedProviderID, text.NewInfoRegistrationContinue(), s.ID())
group := node.DefaultGroup
if s.d.Config().SelfServiceLegacyOIDCRegistrationGroup(ctx) {
group = node.OpenIDConnectGroup
trace.SpanFromContext(r.Context()).AddEvent(semconv.NewDeprecatedFeatureUsedEvent(r.Context(), "legacy_oidc_registration_group"))
}
if traits != nil {
ds, err := rf.IdentitySchema.URL(ctx, s.d.Config())
if err != nil {
return err
}
traitNodes, err := container.NodesFromJSONSchema(ctx, group, ds.String(), "", nil, s.d.Config().SecurityDisallowRefInIdentitySchemas(ctx))
if err != nil {
return err
}
rf.UI.Nodes = append(rf.UI.Nodes, traitNodes...)
rf.UI.UpdateNodeValuesFromJSON(traits, "traits", group)
}
return err
case *settings.Flow:
return err
}
return err
}
func (s *Strategy) populateAccountLinkingUI(ctx context.Context, lf *login.Flow, usedProviderID string, duplicateIdentifier string, availableCredentials []string, availableProviders []string) {
newLoginURL := s.d.Config().SelfServiceFlowLoginUI(ctx).String()
usedProviderLabel := usedProviderID
provider, _ := s.Provider(ctx, usedProviderID)
if provider != nil && provider.Config() != nil {
usedProviderLabel = provider.Config().Label
if usedProviderLabel == "" {
usedProviderLabel = provider.Config().Provider
}
}
loginHintsEnabled := s.d.Config().SelfServiceFlowRegistrationLoginHints(ctx)
nodes := []*node.Node{}
for _, n := range lf.UI.Nodes {
// We don't want to touch nodes unecessary nodes
if n.Meta == nil || n.Meta.Label == nil || n.Group == node.DefaultGroup {
nodes = append(nodes, n)
continue
}
// Skip the provider that was used to get here (in case they used an OIDC provider)
pID := gjson.GetBytes(n.Meta.Label.Context, "provider_id").String()
if n.Group == node.OpenIDConnectGroup {
if pID == usedProviderID {
continue
}
// Hide any provider that is not available for the user
if loginHintsEnabled && !slices.Contains(availableProviders, pID) {
continue
}
}
// Replace some labels to make it easier for the user to understand what's going on.
switch n.Meta.Label.ID {
case text.InfoSelfServiceLogin:
n.Meta.Label = text.NewInfoLoginAndLink()
case text.InfoSelfServiceLoginWith:
p := gjson.GetBytes(n.Meta.Label.Context, "provider").String()
n.Meta.Label = text.NewInfoLoginWithAndLink(p)
default:
// do nothing
}
// This can happen, if login hints are disabled. In that case, we need to make sure to show all credential options.
// It could in theory also happen due to a mis-configuration, and in that case, we should make sure to not delete the entire flow.
if !loginHintsEnabled {
nodes = append(nodes, n)
} else {
// Hide nodes from credentials that are not relevant for the user
for _, ct := range availableCredentials {
if ct == string(n.Group) {
nodes = append(nodes, n)
break
}
}
}
}
// Hide the "primary" identifier field present for Password, webauthn or passwordless, as we already know the identifier
identifierNode := lf.UI.Nodes.Find("identifier")
if identifierNode != nil {
if attributes, ok := identifierNode.Attributes.(*node.InputAttributes); ok {
attributes.Type = node.InputAttributeTypeHidden
attributes.SetValue(duplicateIdentifier)
identifierNode.Attributes = attributes
}
}
lf.UI.Nodes = nodes
lf.UI.Messages.Clear()
lf.UI.Messages.Add(text.NewInfoLoginLinkMessage(duplicateIdentifier, usedProviderLabel, newLoginURL, availableCredentials, availableProviders))
}
func (s *Strategy) NodeGroup() node.UiNodeGroup {
return node.OpenIDConnectGroup
}
func (s *Strategy) CompletedAuthenticationMethod(context.Context) session.AuthenticationMethod {
return session.AuthenticationMethod{
Method: s.ID(),
AAL: identity.AuthenticatorAssuranceLevel1,
}
}
func (s *Strategy) ProcessIDToken(r *http.Request, provider Provider, idToken, idTokenNonce string) (*Claims, error) {
verifier, ok := provider.(IDTokenVerifier)
if !ok {
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("The provider %s does not support id_token verification", provider.Config().Provider)))
}
t0 := time.Now()
claims, err := verifier.Verify(r.Context(), idToken)
reqlog.AccumulateExternalLatency(r.Context(), time.Since(t0))
if err != nil {
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrForbidden().WithReasonf("Could not verify id_token").WithWrap(err).WithError(err.Error())))
}
if err := claims.Validate(); err != nil {
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrForbidden().WithReasonf("The id_token claims were invalid").WithWrap(err)))
}
// First check if the JWT contains the nonce claim.
if claims.Nonce == "" {
// If it doesn't, check if the provider supports nonces.
if nonceSkipper, ok := verifier.(NonceValidationSkipper); !ok || !nonceSkipper.CanSkipNonce(claims) {
// If the provider supports nonces, abort the flow!
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("No nonce was included in the id_token but is required by the provider")))
}
// If the provider does not support nonces, we don't do validation and return the claim.
// This case only applies to Apple, as some of their devices do not support nonces.
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
} else if idTokenNonce == "" {
// A nonce was present in the JWT token, but no nonce was submitted in the flow
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("No nonce was provided but is required by the provider")))
} else if idTokenNonce != claims.Nonce {
// The nonce from the JWT token does not match the nonce from the flow.
return nil, oidcerr.Wrap(oidcerr.StepIDTokenVerify, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("The supplied nonce does not match the nonce from the id_token")))
}
// Nonce checking was successful
return claims, nil
}
func (s *Strategy) linkCredentials(ctx context.Context, i *identity.Identity, tokens *identity.CredentialsOIDCEncryptedTokens, provider, subject, organization string) (err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "strategy.oidc.linkCredentials", trace.WithAttributes(
attribute.String("provider", provider),
// attribute.String("subject", subject), // PII
attribute.String("organization", organization)))
defer otelx.End(span, &err)
if len(i.Credentials) == 0 {
if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil {
return err
}
}
var conf identity.CredentialsOIDC
creds, err := i.ParseCredentials(s.ID(), &conf)
if errors.Is(err, herodot.ErrNotFound()) {
var err error
if creds, err = identity.NewOIDCLikeCredentials(tokens, s.ID(), provider, subject, organization); err != nil {
return err
}
} else if err != nil {
return err
} else {
creds.Identifiers = append(creds.Identifiers, identity.OIDCUniqueID(provider, subject))
conf.Providers = append(conf.Providers, identity.CredentialsOIDCProvider{
Subject: subject,
Provider: provider,
InitialAccessToken: tokens.GetAccessToken(),
InitialRefreshToken: tokens.GetRefreshToken(),
InitialIDToken: tokens.GetIDToken(),
Organization: organization,
})
creds.Config, err = json.Marshal(conf)
if err != nil {
return err
}
}
i.Credentials[s.ID()] = *creds
if orgID, err := uuid.FromString(organization); err == nil {
i.OrganizationID = uuid.NullUUID{UUID: orgID, Valid: true}
}
return nil
}
func getAuthRedirectURL(ctx context.Context, provider Provider, req ider, state string, upstreamParameters map[string]string, opts []oauth2.AuthCodeOption) (codeURL string, err error) {
switch p := provider.(type) {
case OAuth2Provider:
c, err := p.OAuth2(ctx)
if err != nil {
return "", err
}
opts = append(opts, p.AuthCodeURLOptions(req)...)
opts = append(opts, UpstreamParameters(upstreamParameters)...)
return c.AuthCodeURL(state, opts...), nil
case OAuth1Provider:
return p.AuthURL(ctx, state)
default:
return "", errors.WithStack(herodot.ErrInternalServerError().WithReasonf("The provider %s does not support the OAuth 2.0 or OAuth 1.0 protocol", provider.Config().Provider))
}
}
func (s *Strategy) encryptOAuth2Tokens(ctx context.Context, token *oauth2.Token) (et *identity.CredentialsOIDCEncryptedTokens, err error) {
et = new(identity.CredentialsOIDCEncryptedTokens)
if token == nil {
return et, nil
}
if idToken, ok := token.Extra("id_token").(string); ok {
et.IDToken, err = s.d.Cipher(ctx).Encrypt(ctx, []byte(idToken))
if err != nil {
return nil, err
}
}
et.AccessToken, err = s.d.Cipher(ctx).Encrypt(ctx, []byte(token.AccessToken))
if err != nil {
return nil, err
}
et.RefreshToken, err = s.d.Cipher(ctx).Encrypt(ctx, []byte(token.RefreshToken))
if err != nil {
return nil, err
}
return et, nil
}