Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: client-side PKCE support during OIDC and generic redirect_uri #4059

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- sdk-generate
services:
postgres:
image: postgres:11.8
image: postgres:14
env:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: test
Expand Down Expand Up @@ -111,15 +111,15 @@ jobs:
- sdk-generate
services:
postgres:
image: postgres:11.8
image: postgres:14
env:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: test
POSTGRES_USER: test
ports:
- 5432:5432
mysql:
image: mysql:5.7
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test
ports:
Expand Down Expand Up @@ -222,15 +222,15 @@ jobs:
- sdk-generate
services:
postgres:
image: postgres:11.8
image: postgres:14
env:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: test
POSTGRES_USER: test
ports:
- 5432:5432
mysql:
image: mysql:5.7
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test
ports:
Expand Down
7 changes: 7 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,13 @@
"enum": ["id_token", "userinfo"],
"default": "id_token",
"examples": ["id_token", "userinfo"]
},
"pkce": {
"title": "PKCE",
"description": "Controls if the OpenID Connect OAuth2 flow should use PKCE (Proof Key for Code Exchange). Defaults to `auto` (PKCE is used if the provider advertises support for it). IMPORTANT: If you set this to `force`, you must whitelist a different return URL for your OAuth2 client in the provider's configuration. Instead of <base-url>/self-service/methods/oidc/callback/<provider>, you must use <base-url>/self-service/methods/oidc/callback",
"type": "string",
"enum": ["auto", "never", "force"],
"default": "auto"
}
},
"additionalProperties": false,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ require (
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/protobuf v1.34.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
Expand Down
34 changes: 21 additions & 13 deletions internal/testhelpers/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,34 @@ func NewDebugClient(t *testing.T) *http.Client {
return &http.Client{Transport: NewTransportWithLogger(http.DefaultTransport, t)}
}

func NewClientWithCookieJar(t *testing.T, jar *cookiejar.Jar, debugRedirects bool) *http.Client {
func NewClientWithCookieJar(t *testing.T, jar *cookiejar.Jar, checkRedirect CheckRedirectFunc) *http.Client {
if jar == nil {
j, err := cookiejar.New(nil)
jar = j
require.NoError(t, err)
}
if checkRedirect == nil {
checkRedirect = DebugRedirects(t)
}
return &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if debugRedirects {
t.Logf("Redirect: %s", req.URL.String())
}
if len(via) >= 20 {
for k, v := range via {
t.Logf("Failed with redirect (%d): %s", k, v.URL.String())
}
return errors.New("stopped after 20 redirects")
Jar: jar,
CheckRedirect: checkRedirect,
}
}

type CheckRedirectFunc func(req *http.Request, via []*http.Request) error

func DebugRedirects(t *testing.T) CheckRedirectFunc {
return func(req *http.Request, via []*http.Request) error {
t.Logf("Redirect: %s", req.URL.String())

if len(via) >= 20 {
for k, v := range via {
t.Logf("Failed with redirect (%d): %s", k, v.URL.String())
}
return nil
},
return errors.New("stopped after 20 redirects")
}
return nil
}
}

Expand Down
4 changes: 2 additions & 2 deletions quickstart-mysql.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: '3.7'
version: "3.7"

services:
kratos-migrate:
Expand All @@ -10,7 +10,7 @@ services:
- DSN=mysql://root:secret@tcp(mysqld:3306)/mysql?max_conns=20&max_idle_conns=4

mysqld:
image: mysql:5.7
image: mysql:8.0
ports:
- "3306:3306"
environment:
Expand Down
4 changes: 2 additions & 2 deletions quickstart-postgres.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: '3.7'
version: "3.7"

services:
kratos-migrate:
Expand All @@ -10,7 +10,7 @@ services:
- DSN=postgres://kratos:secret@postgresd:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4

postgresd:
image: postgres:11.8
image: postgres:14
ports:
- "5432:5432"
environment:
Expand Down
8 changes: 4 additions & 4 deletions script/testenv.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
#!/usr/bin/env bash

docker rm -f kratos_test_database_mysql kratos_test_database_postgres kratos_test_database_cockroach kratos_test_hydra || true
docker run --platform linux/amd64 --name kratos_test_database_mysql -p 3444:3306 -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0.34
docker run --platform linux/amd64 --name kratos_test_database_postgres -p 3445:5432 -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=postgres -d postgres:11.8 postgres -c log_statement=all
docker run --platform linux/amd64 --name kratos_test_database_cockroach -p 3446:26257 -p 3447:8080 -d cockroachdb/cockroach:v22.2.6 start-single-node --insecure
docker run --platform linux/amd64 --name kratos_test_hydra -p 4444:4444 -p 4445:4445 -d -e DSN=memory -e URLS_SELF_ISSUER=http://localhost:4444/ -e URLS_LOGIN=http://localhost:4446/login -e URLS_CONSENT=http://localhost:4446/consent oryd/hydra:v2.0.2 serve all --dev
docker run --name kratos_test_database_mysql -p 3444:3306 -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0
docker run --name kratos_test_database_postgres -p 3445:5432 -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=postgres -d postgres:14 postgres -c log_statement=all
docker run --name kratos_test_database_cockroach -p 3446:26257 -p 3447:8080 -d cockroachdb/cockroach:v22.2.6 start-single-node --insecure
docker run --name kratos_test_hydra -p 4444:4444 -p 4445:4445 -d -e DSN=memory -e URLS_SELF_ISSUER=http://localhost:4444/ -e URLS_LOGIN=http://localhost:4446/login -e URLS_CONSENT=http://localhost:4446/consent oryd/hydra:v2.0.2 serve all --dev

source script/test-envs.sh
5 changes: 4 additions & 1 deletion selfservice/flow/login/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ type Flow struct {
ReturnToVerification string `json:"-" db:"-"`
}

var _ flow.Flow = new(Flow)
var _ interface {
flow.Flow
flow.InternalContexter
} = (*Flow)(nil)

func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, flowType flow.Type) (*Flow, error) {
now := time.Now().UTC()
Expand Down
5 changes: 4 additions & 1 deletion selfservice/flow/registration/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ type Flow struct {
RawIDTokenNonce string `json:"-" db:"-"`
}

var _ flow.Flow = new(Flow)
var _ interface {
flow.Flow
flow.InternalContexter
} = (*Flow)(nil)

func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, ft flow.Type) (*Flow, error) {
now := time.Now().UTC()
Expand Down
13 changes: 12 additions & 1 deletion selfservice/flow/settings/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ type Flow struct {
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
}

var _ flow.Flow = new(Flow)
var _ interface {
flow.Flow
flow.InternalContexter
} = (*Flow)(nil)

func MustNewFlow(conf *config.Config, exp time.Duration, r *http.Request, i *identity.Identity, ft flow.Type) *Flow {
f, err := NewFlow(conf, exp, r, i, ft)
Expand Down Expand Up @@ -212,6 +215,14 @@ func (f *Flow) EnsureInternalContext() {
}
}

func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage {
return f.InternalContext
}

func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) {
f.InternalContext = bytes
}

func (f Flow) MarshalJSON() ([]byte, error) {
type local Flow
f.SetReturnTo()
Expand Down
4 changes: 2 additions & 2 deletions selfservice/strategy/code/strategy_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow,
}
return nil, nil
case flow.StateEmailSent:
i, err := s.loginVerifyCode(ctx, r, f, &p, sess)
i, err := s.loginVerifyCode(ctx, f, &p, sess)
if err != nil {
return nil, s.HandleLoginError(r, f, &p, err)
}
Expand Down Expand Up @@ -437,7 +437,7 @@ func maybeNormalizeEmail(input string) string {
return input
}

func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *login.Flow, p *updateLoginFlowWithCodeMethod, sess *session.Session) (_ *identity.Identity, err error) {
func (s *Strategy) loginVerifyCode(ctx context.Context, f *login.Flow, p *updateLoginFlowWithCodeMethod, sess *session.Session) (_ *identity.Identity, err error) {
ctx, span := s.deps.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.code.strategy.loginVerifyCode")
defer otelx.End(span, &err)

Expand Down
18 changes: 9 additions & 9 deletions selfservice/strategy/code/strategy_recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}
case flow.TypeAPI:
if err := s.deps.SessionPersister().UpsertSession(r.Context(), sess); err != nil {
if err := s.deps.SessionPersister().UpsertSession(ctx, sess); err != nil {
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}
f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSetToken(sess.Token))
Expand All @@ -217,7 +217,7 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}

returnToURL := s.deps.Config().SelfServiceFlowRecoveryReturnTo(r.Context(), nil)
returnToURL := s.deps.Config().SelfServiceFlowRecoveryReturnTo(ctx, nil)
returnTo := ""
if returnToURL != nil {
returnTo = returnToURL.String()
Expand All @@ -230,12 +230,12 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
config := s.deps.Config()

sf.UI.Messages.Set(text.NewRecoverySuccessful(time.Now().Add(config.SelfServiceFlowSettingsPrivilegedSessionMaxAge(ctx))))
if err := s.deps.SettingsFlowPersister().UpdateSettingsFlow(r.Context(), sf); err != nil {
if err := s.deps.SettingsFlowPersister().UpdateSettingsFlow(ctx, sf); err != nil {
return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err))
}

if s.deps.Config().UseContinueWithTransitions(ctx) {
redirectTo := sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String()
redirectTo := sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(ctx)).String()
switch {
case f.Type.IsAPI(), x.IsJSONRequest(r):
f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf, redirectTo))
Expand All @@ -245,9 +245,9 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
}
} else {
if x.IsJSONRequest(r) {
s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String()))
s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(ctx)).String()))
} else {
http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther)
http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
}
}

Expand All @@ -265,7 +265,7 @@ func (s *Strategy) recoveryUseCode(w http.ResponseWriter, r *http.Request, body
}

if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther)
} else {
s.deps.Writer().Write(w, r, f)
}
Expand Down Expand Up @@ -395,7 +395,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R
// re-initialize the UI with a "clean" new state
f.UI = &container.Container{
Method: "POST",
Action: flow.AppendFlowTo(urlx.AppendPaths(s.deps.Config().SelfPublicURL(r.Context()), recovery.RouteSubmitFlow), f.ID).String(),
Action: flow.AppendFlowTo(urlx.AppendPaths(s.deps.Config().SelfPublicURL(ctx), recovery.RouteSubmitFlow), f.ID).String(),
}

f.UI.SetCSRF(s.deps.GenerateCSRFToken(r))
Expand All @@ -420,7 +420,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R
f.UI.Nodes.Append(node.NewInputField("email", body.Email, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoNodeResendOTP()),
)
if err := s.deps.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil {
if err := s.deps.RecoveryFlowPersister().UpdateRecoveryFlow(ctx, f); err != nil {
return s.HandleRecoveryError(w, r, f, body, err)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,72 @@
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "provider",
"type": "submit",
"value": "neverPKCE",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040002,
"text": "Sign up with neverPKCE",
"type": "info",
"context": {
"provider": "neverPKCE"
}
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "provider",
"type": "submit",
"value": "autoPKCE",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040002,
"text": "Sign up with autoPKCE",
"type": "info",
"context": {
"provider": "autoPKCE"
}
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "provider",
"type": "submit",
"value": "forcePKCE",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040002,
"text": "Sign up with forcePKCE",
"type": "info",
"context": {
"provider": "forcePKCE"
}
}
}
},
{
"type": "input",
"group": "oidc",
Expand Down
Loading
Loading