snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
388
selfservice/strategy/oidc/strategy_helper_test.go
Normal file
388
selfservice/strategy/oidc/strategy_helper_test.go
Normal file
@@ -0,0 +1,388 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/phayes/freeport"
|
||||
"github.com/rakutentech/jwk-go/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
|
||||
"github.com/ory/dockertest/v4"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/strategy/oidc"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/ioutilx"
|
||||
)
|
||||
|
||||
type idTokenClaims struct {
|
||||
traits struct {
|
||||
website string
|
||||
groups []string
|
||||
}
|
||||
metadataPublic struct {
|
||||
picture string
|
||||
}
|
||||
metadataAdmin struct {
|
||||
phoneNumber string
|
||||
}
|
||||
}
|
||||
|
||||
func (token idTokenClaims) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
IDToken struct {
|
||||
Website string `json:"website,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
} `json:"id_token"`
|
||||
}{
|
||||
IDToken: struct {
|
||||
Website string `json:"website,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
}{
|
||||
Website: token.traits.website,
|
||||
Groups: token.traits.groups,
|
||||
Picture: token.metadataPublic.picture,
|
||||
PhoneNumber: token.metadataAdmin.phoneNumber,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func createClient(t *testing.T, remote string, redir []string) (id, secret string) {
|
||||
client := testhelpers.NewTestClient(t)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
var b bytes.Buffer
|
||||
require.NoError(t, json.NewEncoder(&b).Encode(&struct {
|
||||
Scope string `json:"scope"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
}{
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
Scope: "offline offline_access openid",
|
||||
RedirectURIs: redir,
|
||||
|
||||
// This is a workaround to prevent golang.org/x/oauth2 from
|
||||
// swallowing the actual error messages from failed token exchanges.
|
||||
//
|
||||
// The library first attempts to use the Authorization header to
|
||||
// pass Client ID+secret during token exchange (client_secret_basic
|
||||
// in Hydra terminology). If that fails (with any error), it tries
|
||||
// again with the Client ID+secret passed in the HTTP POST body
|
||||
// (client_secret_post in Hydra). If that also fails, this second
|
||||
// error is returned.
|
||||
//
|
||||
// Now, if the the client was indeed configured to use
|
||||
// client_secret_basic, but the token exchange fails for another
|
||||
// reason, the error message will be swallowed and replaced with
|
||||
// "invalid_client".
|
||||
//
|
||||
// Manually setting this to client_secret_post means that during
|
||||
// tests, all token exchanges will first fail with `invalid_client`
|
||||
// and then be retried with the correct method. This is the only way
|
||||
// to get the actual error message from the server, however.
|
||||
//
|
||||
// https://github.com/golang/oauth2/blob/5fd42413edb3b1699004a31b72e485e0e4ba1b13/pkg/token.go#L227-L242
|
||||
TokenEndpointAuthMethod: "client_secret_post",
|
||||
}))
|
||||
|
||||
res, err := client.Post(remote+"/admin/clients", "application/json", &b)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body := ioutilx.MustReadAll(res.Body)
|
||||
require.Equal(t, http.StatusCreated, res.StatusCode)
|
||||
|
||||
id = gjson.GetBytes(body, "client_id").String()
|
||||
secret = gjson.GetBytes(body, "client_secret").String()
|
||||
}, time.Minute, time.Second)
|
||||
return
|
||||
}
|
||||
|
||||
type hydraFlowParams struct {
|
||||
subject string
|
||||
claims idTokenClaims
|
||||
scope []string
|
||||
// acr, when non-nil, is returned as the upstream `acr`
|
||||
// claim on the login accept. The empty string is omitted from the
|
||||
// payload (the harness uses `omitempty` on the wire format).
|
||||
acr *string
|
||||
// amr, when non-nil, is returned as the upstream `amr`
|
||||
// claim on the login accept. An empty slice is omitted from the
|
||||
// payload (the harness uses `omitempty` on the wire format).
|
||||
amr []string
|
||||
}
|
||||
|
||||
func wrapClientForHydraLoginConsent(t *testing.T, wrapped *http.Client, hydraAdmin string, params hydraFlowParams) *http.Client {
|
||||
adminClient := testhelpers.NewTestClient(t)
|
||||
doPut := func(href, payload string) string {
|
||||
req, err := http.NewRequest("PUT", hydraAdmin+href, strings.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := adminClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body := ioutilx.MustReadAll(res.Body)
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
|
||||
redirectTo := gjson.GetBytes(body, "redirect_to")
|
||||
require.Truef(t, redirectTo.Exists(), "%s", body)
|
||||
require.NotEmptyf(t, redirectTo.Str, "%s", body)
|
||||
|
||||
return redirectTo.Str
|
||||
}
|
||||
|
||||
c := *wrapped
|
||||
tsp := c.Transport
|
||||
if tsp == nil {
|
||||
tsp = http.DefaultTransport
|
||||
}
|
||||
c.Transport = httpx.TransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.HasPrefix(req.URL.String(), hydraLoginURL) {
|
||||
challenge := req.URL.Query().Get("login_challenge")
|
||||
require.NotEmptyf(t, challenge, "%s", req.URL)
|
||||
|
||||
payload := map[string]any{
|
||||
"subject": params.subject,
|
||||
}
|
||||
if params.acr != nil {
|
||||
payload["acr"] = *params.acr
|
||||
}
|
||||
if params.amr != nil {
|
||||
payload["amr"] = params.amr
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
redirectTo := doPut("/admin/oauth2/auth/requests/login/accept?login_challenge="+challenge, string(body))
|
||||
req, err := http.NewRequest("GET", redirectTo, nil)
|
||||
require.NoError(t, err)
|
||||
return c.Do(req)
|
||||
}
|
||||
if strings.HasPrefix(req.URL.String(), hydraConsentURL) {
|
||||
challenge := req.URL.Query().Get("consent_challenge")
|
||||
require.NotEmptyf(t, challenge, "%s", req.URL)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"grant_scope": params.scope,
|
||||
"session": params.claims,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
redirectTo := doPut("/admin/oauth2/auth/requests/consent/accept?consent_challenge="+challenge, string(body))
|
||||
req, err := http.NewRequest("GET", redirectTo, nil)
|
||||
require.NoError(t, err)
|
||||
return c.Do(req)
|
||||
}
|
||||
return tsp.RoundTrip(req)
|
||||
})
|
||||
return &c
|
||||
}
|
||||
|
||||
func newUI(t *testing.T, reg driver.Registry) *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var e interface{}
|
||||
var err error
|
||||
switch r.URL.Path {
|
||||
case "/login":
|
||||
e, err = reg.LoginFlowPersister().GetLoginFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow")))
|
||||
case "/registration":
|
||||
e, err = reg.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow")))
|
||||
case "/settings":
|
||||
e, err = reg.SettingsFlowPersister().GetSettingsFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow")))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
reg.Writer().Write(w, r, err)
|
||||
return
|
||||
}
|
||||
reg.Writer().Write(w, r, e)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
reg.Config().MustSet(t.Context(), config.ViperKeySelfServiceLoginUI, ts.URL+"/login")
|
||||
reg.Config().MustSet(t.Context(), config.ViperKeySelfServiceRegistrationUI, ts.URL+"/registration")
|
||||
reg.Config().MustSet(t.Context(), config.ViperKeySelfServiceSettingsURL, ts.URL+"/settings")
|
||||
return ts
|
||||
}
|
||||
|
||||
// These URLs are not actually resolving, but instead the client is catching the redirect and handling it.
|
||||
const (
|
||||
hydraLoginURL = "https://hydra-placeholder-ui/login"
|
||||
hydraConsentURL = "https://hydra-placeholder-ui/consent"
|
||||
)
|
||||
|
||||
func newHydra(t *testing.T) (remoteAdmin, remotePublic string) {
|
||||
remoteAdmin = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_ADMIN")
|
||||
remotePublic = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_PUBLIC")
|
||||
|
||||
start := time.Now()
|
||||
if remotePublic == "" && remoteAdmin == "" {
|
||||
t.Logf("Environment did not provide Ory Hydra, starting fresh.")
|
||||
pool := dockertest.NewPoolT(t, "")
|
||||
|
||||
// The public host port must be known before the container starts because
|
||||
// it is part of URLS_SELF_ISSUER. freeport releases the port before Docker
|
||||
// binds it, so another process can grab it in between — retry on that race.
|
||||
var hydra dockertest.ClosableResource
|
||||
ctx := t.Context()
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
publicPort, err := freeport.GetFreePort()
|
||||
require.NoError(t, err)
|
||||
|
||||
hydra, err = pool.Run(ctx, "oryd/hydra",
|
||||
// Keep tag in sync with the version in ci.yaml
|
||||
dockertest.WithTag("v2.2.0"),
|
||||
dockertest.WithoutReuse(),
|
||||
dockertest.WithEnv([]string{
|
||||
"DSN=memory",
|
||||
fmt.Sprintf("URLS_SELF_ISSUER=http://127.0.0.1:%d/", publicPort),
|
||||
"URLS_LOGIN=" + hydraLoginURL,
|
||||
"URLS_CONSENT=" + hydraConsentURL,
|
||||
"LOG_LEAK_SENSITIVE_VALUES=true",
|
||||
"SECRETS_SYSTEM=someverylongsecretthatis32byteslong",
|
||||
}),
|
||||
dockertest.WithCmd([]string{"serve", "all", "--dev"}),
|
||||
dockertest.WithContainerConfig(func(cc *container.Config) {
|
||||
cc.ExposedPorts = network.PortSet{
|
||||
network.MustParsePort("4444/tcp"): struct{}{},
|
||||
network.MustParsePort("4445/tcp"): struct{}{},
|
||||
}
|
||||
}),
|
||||
dockertest.WithHostConfig(func(hc *container.HostConfig) {
|
||||
hc.PortBindings = network.PortMap{
|
||||
network.MustParsePort("4444/tcp"): {{HostPort: strconv.Itoa(publicPort)}},
|
||||
network.MustParsePort("4445/tcp"): {{HostPort: ""}},
|
||||
}
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}, 2*time.Minute, 100*time.Millisecond)
|
||||
hydra.Cleanup(t)
|
||||
require.NotEmpty(t, hydra.GetPort("4444/tcp"), "%+v", hydra.Container().NetworkSettings.Ports)
|
||||
require.NotEmpty(t, hydra.GetPort("4445/tcp"), "%+v", hydra.Container)
|
||||
|
||||
remotePublic = "http://127.0.0.1:" + hydra.GetPort("4444/tcp")
|
||||
remoteAdmin = "http://127.0.0.1:" + hydra.GetPort("4445/tcp")
|
||||
|
||||
client := testhelpers.NewTestClient(t)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
res, err := client.Get(remotePublic + "/health/ready")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
res, err = client.Get(remotePublic + "/.well-known/openid-configuration")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
res, err = client.Get(remoteAdmin + "/health/ready")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
t.Logf("Ory Hydra started after %s at: %s %s", time.Since(start), remotePublic, remoteAdmin)
|
||||
|
||||
return remoteAdmin, remotePublic
|
||||
}
|
||||
|
||||
func newOIDCProvider(
|
||||
t *testing.T,
|
||||
kratos *httptest.Server,
|
||||
hydraPublic string,
|
||||
hydraAdmin string,
|
||||
id string,
|
||||
opts ...func(*oidc.Configuration),
|
||||
) oidc.Configuration {
|
||||
clientID, secret := createClient(t, hydraAdmin, []string{kratos.URL + oidc.RouteBase + "/callback/" + id, kratos.URL + oidc.RouteCallbackGeneric})
|
||||
|
||||
cfg := oidc.Configuration{
|
||||
Provider: "generic",
|
||||
ID: id,
|
||||
ClientID: clientID,
|
||||
ClientSecret: secret,
|
||||
IssuerURL: hydraPublic + "/",
|
||||
Mapper: "file://./stub/oidc.hydra.jsonnet",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func setProviderConfig(t *testing.T, conf *config.Config, providers ...oidc.Configuration) {
|
||||
baseKey := fmt.Sprintf("%s.%s", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeOIDC)
|
||||
currentConfig := conf.GetProvider(t.Context()).Get(baseKey + ".config")
|
||||
currentEnabled := conf.GetProvider(t.Context()).Get(baseKey + ".enabled")
|
||||
|
||||
conf.MustSet(t.Context(), baseKey+".config", &oidc.ConfigurationCollection{Providers: providers})
|
||||
conf.MustSet(t.Context(), baseKey+".enabled", true)
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), baseKey+".config", currentConfig)
|
||||
conf.MustSet(t.Context(), baseKey+".enabled", currentEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
// AssertSystemError asserts an error ui response
|
||||
func AssertSystemError(t *testing.T, errTS *httptest.Server, res *http.Response, body []byte, code int, reason string) {
|
||||
require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
|
||||
|
||||
assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body)
|
||||
assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", body)
|
||||
}
|
||||
|
||||
//go:embed stub/jwk.json
|
||||
var rawKey []byte
|
||||
|
||||
//go:embed stub/jwks_public.json
|
||||
var publicJWKS []byte
|
||||
|
||||
// Just a public key set, to be able to test what happens if an ID token was issued by a different private key.
|
||||
//
|
||||
//go:embed stub/jwks_public2.json
|
||||
var publicJWKS2 []byte
|
||||
|
||||
func createIDToken(t *testing.T, cl jwt.RegisteredClaims) string {
|
||||
key := &jwk.KeySpec{}
|
||||
require.NoError(t, json.Unmarshal(rawKey, key))
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, &struct {
|
||||
*jwt.RegisteredClaims
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
RegisteredClaims: &cl,
|
||||
Email: "acme@ory.sh",
|
||||
})
|
||||
token.Header["kid"] = key.KeyID
|
||||
s, err := token.SignedString(key.Key)
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user