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
998 lines
35 KiB
Go
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
|
|
}
|