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
389 lines
13 KiB
Go
389 lines
13 KiB
Go
// 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
|
|
}
|