diff --git a/.gitignore b/.gitignore index d91c2fb7d111..f792761b2b71 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ heap_profiler/ goroutine_dump/ inflight_trace_dump/ +contrib/quickstart/kratos/oidc + e2e/*.log e2e/kratos.*.yml e2e/proxy.json diff --git a/driver/config/config_test.go b/driver/config/config_test.go index b2047e2fc433..2a8d57e7bebb 100644 --- a/driver/config/config_test.go +++ b/driver/config/config_test.go @@ -15,7 +15,6 @@ import ( "os" "path/filepath" "strings" - "sync" "testing" "time" @@ -1043,35 +1042,23 @@ func TestIdentitySchemaValidation(t *testing.T) { t.Cleanup(cancel) _, hook, writeSchema := testWatch(t, ctx, &cobra.Command{}, identity) - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - // Change the identity config to an invalid file - writeSchema(invalidIdentity.Identity.Schemas) - }() + writeSchema(invalidIdentity.Identity.Schemas) // There are a bunch of log messages beeing logged. We are looking for a specific one. - timeout := time.After(time.Millisecond * 500) - success := false - for !success { + for { for _, v := range hook.AllEntries() { s, err := v.String() require.NoError(t, err) - success = success || strings.Contains(s, "The changed identity schema configuration is invalid and could not be loaded.") + if strings.Contains(s, "The changed identity schema configuration is invalid and could not be loaded.") { + return + } } - select { case <-ctx.Done(): t.Fatal("the test could not complete as the context timed out before the file watcher updated") - case <-timeout: - t.Fatal("Expected log line was not encountered within specified timeout") default: // nothing } } - - wg.Wait() }) } }) diff --git a/embedx/config.schema.json b/embedx/config.schema.json index c7a7be169224..473f0bf514fd 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -436,6 +436,7 @@ "dingtalk", "patreon", "linkedin", + "linkedin_v2", "lark", "x" ], diff --git a/identity/.snapshots/TestHandler-case=should_list_all_identities_with_credentials-include_credential=oidc_should_include_OIDC_credentials_config.json b/identity/.snapshots/TestHandler-case=should_list_all_identities_with_credentials-include_credential=oidc_should_include_OIDC_credentials_config.json new file mode 100644 index 000000000000..95bff506986a --- /dev/null +++ b/identity/.snapshots/TestHandler-case=should_list_all_identities_with_credentials-include_credential=oidc_should_include_OIDC_credentials_config.json @@ -0,0 +1 @@ +"{\"providers\":[{\"initial_id_token\":\"id_token0\",\"initial_access_token\":\"access_token0\",\"initial_refresh_token\":\"refresh_token0\",\"subject\":\"foo\",\"provider\":\"bar\",\"organization\":\"\"},{\"initial_id_token\":\"id_token1\",\"initial_access_token\":\"access_token1\",\"initial_refresh_token\":\"refresh_token1\",\"subject\":\"baz\",\"provider\":\"zab\",\"organization\":\"\"}]}" diff --git a/identity/handler.go b/identity/handler.go index 0343567a0ac7..8622a2e76d8e 100644 --- a/identity/handler.go +++ b/identity/handler.go @@ -251,7 +251,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request, _ httprouter.Para } // Identities using the marshaler for including metadata_admin - isam := make([]WithCredentialsMetadataAndAdminMetadataInJSON, len(is)) + isam := make([]WithCredentialsAndAdminMetadataInJSON, len(is)) for i, identity := range is { emit, err := identity.WithDeclassifiedCredentials(r.Context(), h.r, params.DeclassifyCredentials) if err != nil { @@ -259,7 +259,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request, _ httprouter.Para return } - isam[i] = WithCredentialsMetadataAndAdminMetadataInJSON(*emit) + isam[i] = WithCredentialsAndAdminMetadataInJSON(*emit) } h.r.Writer().Write(w, r, isam) diff --git a/identity/handler_test.go b/identity/handler_test.go index 9444da92a0e8..bfdf1a86dfc3 100644 --- a/identity/handler_test.go +++ b/identity/handler_test.go @@ -1348,11 +1348,15 @@ func TestHandler(t *testing.T) { }) t.Run("case=should list all identities with credentials", func(t *testing.T) { - res := get(t, adminTS, "/identities?include_credential=totp", http.StatusOK) - assert.True(t, res.Get("0.credentials").Exists(), "credentials config should be included: %s", res.Raw) - assert.True(t, res.Get("0.metadata_public").Exists(), "metadata_public config should be included: %s", res.Raw) - assert.True(t, res.Get("0.metadata_admin").Exists(), "metadata_admin config should be included: %s", res.Raw) - assert.EqualValues(t, "baz", res.Get(`#(traits.bar=="baz").traits.bar`).String(), "%s", res.Raw) + t.Run("include_credential=oidc should include OIDC credentials config", func(t *testing.T) { + res := get(t, adminTS, "/identities?include_credential=oidc&credentials_identifier=bar:foo.oidc@bar.com", http.StatusOK) + assert.True(t, res.Get("0.credentials.oidc.config").Exists(), "credentials config should be included: %s", res.Raw) + snapshotx.SnapshotT(t, res.Get("0.credentials.oidc.config").String()) + }) + t.Run("include_credential=totp should not include OIDC credentials config", func(t *testing.T) { + res := get(t, adminTS, "/identities?include_credential=totp&credentials_identifier=bar:foo.oidc@bar.com", http.StatusOK) + assert.False(t, res.Get("0.credentials.oidc.config").Exists(), "credentials config should be included: %s", res.Raw) + }) }) t.Run("case=should not be able to list all identities with credentials due to wrong credentials type", func(t *testing.T) { diff --git a/internal/client-go/go.sum b/internal/client-go/go.sum index c966c8ddfd0d..6cc3f5911d11 100644 --- a/internal/client-go/go.sum +++ b/internal/client-go/go.sum @@ -4,6 +4,7 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/selfservice/strategy/oidc/provider.go b/selfservice/strategy/oidc/provider.go index cb22ebb6b847..30ea305a22ed 100644 --- a/selfservice/strategy/oidc/provider.go +++ b/selfservice/strategy/oidc/provider.go @@ -5,8 +5,10 @@ package oidc import ( "context" + "encoding/json" "net/http" "net/url" + "strings" "github.com/dghubble/oauth1" "github.com/pkg/errors" @@ -68,7 +70,7 @@ type Claims struct { Gender string `json:"gender,omitempty"` Birthdate string `json:"birthdate,omitempty"` Zoneinfo string `json:"zoneinfo,omitempty"` - Locale string `json:"locale,omitempty"` + Locale Locale `json:"locale,omitempty"` PhoneNumber string `json:"phone_number,omitempty"` PhoneNumberVerified bool `json:"phone_number_verified,omitempty"` UpdatedAt int64 `json:"updated_at,omitempty"` @@ -79,6 +81,29 @@ type Claims struct { RawClaims map[string]interface{} `json:"raw_claims,omitempty"` } +type Locale string + +func (l *Locale) UnmarshalJSON(data []byte) error { + var linkedInLocale struct { + Language string `json:"language"` + Country string `json:"country"` + } + if err := json.Unmarshal(data, &linkedInLocale); err == nil { + switch { + case linkedInLocale.Language == "": + *l = Locale(linkedInLocale.Country) + case linkedInLocale.Country == "": + *l = Locale(linkedInLocale.Language) + default: + *l = Locale(strings.Join([]string{linkedInLocale.Language, linkedInLocale.Country}, "-")) + } + + return nil + } + + return json.Unmarshal(data, (*string)(l)) +} + // Validate checks if the claims are valid. func (c *Claims) Validate() error { if c.Subject == "" { diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index 0e579906a94f..4eac8be4f7db 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -141,26 +141,27 @@ type ConfigurationCollection struct { // If you add a provider here, please also add a test to // provider_private_net_test.go var supportedProviders = map[string]func(config *Configuration, reg Dependencies) Provider{ - "generic": NewProviderGenericOIDC, - "google": NewProviderGoogle, - "github": NewProviderGitHub, - "github-app": NewProviderGitHubApp, - "gitlab": NewProviderGitLab, - "microsoft": NewProviderMicrosoft, - "discord": NewProviderDiscord, - "slack": NewProviderSlack, - "facebook": NewProviderFacebook, - "auth0": NewProviderAuth0, - "vk": NewProviderVK, - "yandex": NewProviderYandex, - "apple": NewProviderApple, - "spotify": NewProviderSpotify, - "netid": NewProviderNetID, - "dingtalk": NewProviderDingTalk, - "linkedin": NewProviderLinkedIn, - "patreon": NewProviderPatreon, - "lark": NewProviderLark, - "x": NewProviderX, + "generic": NewProviderGenericOIDC, + "google": NewProviderGoogle, + "github": NewProviderGitHub, + "github-app": NewProviderGitHubApp, + "gitlab": NewProviderGitLab, + "microsoft": NewProviderMicrosoft, + "discord": NewProviderDiscord, + "slack": NewProviderSlack, + "facebook": NewProviderFacebook, + "auth0": NewProviderAuth0, + "vk": NewProviderVK, + "yandex": NewProviderYandex, + "apple": NewProviderApple, + "spotify": NewProviderSpotify, + "netid": NewProviderNetID, + "dingtalk": NewProviderDingTalk, + "linkedin": NewProviderLinkedIn, + "linkedin_v2": NewProviderLinkedInV2, + "patreon": NewProviderPatreon, + "lark": NewProviderLark, + "x": NewProviderX, } func (c ConfigurationCollection) Provider(id string, reg Dependencies) (Provider, error) { diff --git a/selfservice/strategy/oidc/provider_discord.go b/selfservice/strategy/oidc/provider_discord.go index 181e7df6d322..99bea24d5770 100644 --- a/selfservice/strategy/oidc/provider_discord.go +++ b/selfservice/strategy/oidc/provider_discord.go @@ -93,7 +93,7 @@ func (d *ProviderDiscord) Claims(ctx context.Context, exchange *oauth2.Token, qu Picture: user.AvatarURL(""), Email: user.Email, EmailVerified: x.ConvertibleBoolean(user.Verified), - Locale: user.Locale, + Locale: Locale(user.Locale), } return claims, nil diff --git a/selfservice/strategy/oidc/provider_linkedin_v2.go b/selfservice/strategy/oidc/provider_linkedin_v2.go new file mode 100644 index 000000000000..7ce40239ef46 --- /dev/null +++ b/selfservice/strategy/oidc/provider_linkedin_v2.go @@ -0,0 +1,47 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "net/url" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type ProviderLinkedInV2 struct { + *ProviderGenericOIDC +} + +func NewProviderLinkedInV2( + config *Configuration, + reg Dependencies, +) Provider { + config.ClaimsSource = ClaimsSourceUserInfo + config.IssuerURL = "https://www.linkedin.com/oauth" + + return &ProviderLinkedInV2{ + ProviderGenericOIDC: &ProviderGenericOIDC{ + config: config, + reg: reg, + }, + } +} + +func (l *ProviderLinkedInV2) wrapCtx(ctx context.Context) context.Context { + // We need to overwrite the issuer here because the discovery URL is under + // `https://www.linkedin.com/oauth/.well-known/openid-configuration`, wherease + // the issuer is `https://www.linkedin.com` (without the `/oauth`). This is + // not conformant according to the OIDC spec, but needed for LinkedIn. + return gooidc.InsecureIssuerURLContext(ctx, "https://www.linkedin.com") +} + +func (l *ProviderLinkedInV2) OAuth2(ctx context.Context) (*oauth2.Config, error) { + return l.ProviderGenericOIDC.OAuth2(l.wrapCtx(ctx)) +} + +func (l *ProviderLinkedInV2) Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error) { + return l.ProviderGenericOIDC.Claims(l.wrapCtx(ctx), exchange, query) +} diff --git a/selfservice/strategy/oidc/provider_linkedin_v2_test.go b/selfservice/strategy/oidc/provider_linkedin_v2_test.go new file mode 100644 index 000000000000..c36e44473fa7 --- /dev/null +++ b/selfservice/strategy/oidc/provider_linkedin_v2_test.go @@ -0,0 +1,34 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/internal" + "github.com/ory/kratos/selfservice/strategy/oidc" +) + +func TestProviderLinkedInV2_Discovery(t *testing.T) { + _, reg := internal.NewVeryFastRegistryWithoutDB(t) + + p := oidc.NewProviderLinkedInV2(&oidc.Configuration{ + Provider: "linkedin_v2", + ID: "valid", + ClientID: "client", + ClientSecret: "secret", + Mapper: "file://./stub/hydra.schema.json", + RequestedClaims: nil, + Scope: []string{"email", "profile", "offline_access"}, + }, reg) + + c, err := p.(oidc.OAuth2Provider).OAuth2(context.Background()) + require.NoError(t, err) + assert.Contains(t, c.Scopes, "openid") + assert.Equal(t, "https://www.linkedin.com/oauth/v2/accessToken", c.Endpoint.TokenURL) +} diff --git a/selfservice/strategy/oidc/provider_private_net_test.go b/selfservice/strategy/oidc/provider_private_net_test.go index e656ee0462bb..0505a3e19626 100644 --- a/selfservice/strategy/oidc/provider_private_net_test.go +++ b/selfservice/strategy/oidc/provider_private_net_test.go @@ -73,6 +73,7 @@ func TestProviderPrivateIP(t *testing.T) { // GitHub uses a fixed token URL and does not use the issuer. // GitHub App uses a fixed token URL and does not use the issuer. // GitHub App uses a fixed token URL and does not use the issuer. + // LinkedInV2 uses a fixed token URL and does not use the issuer. {p: gitlab, c: &oidc.Configuration{IssuerURL: "http://127.0.0.2/"}, e: "is not a permitted destination"}, // The TokenURL is fixed in GitLab to {issuer_url}/token. Since the issuer is called first, any local token fails also. diff --git a/selfservice/strategy/oidc/provider_test.go b/selfservice/strategy/oidc/provider_test.go index 7c0de7c55138..a5733d2e95f8 100644 --- a/selfservice/strategy/oidc/provider_test.go +++ b/selfservice/strategy/oidc/provider_test.go @@ -9,6 +9,7 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,7 +43,7 @@ func RegisterTestProvider(id string) func() { var _ IDTokenVerifier = new(TestProvider) -func (t *TestProvider) Verify(ctx context.Context, token string) (*Claims, error) { +func (t *TestProvider) Verify(_ context.Context, token string) (*Claims, error) { if token == "error" { return nil, fmt.Errorf("stub error") } @@ -52,3 +53,56 @@ func (t *TestProvider) Verify(ctx context.Context, token string) (*Claims, error } return &c, nil } + +func TestLocale(t *testing.T) { + // test json unmarshal + for _, tc := range []struct { + name string + json string + expected string + assertErr assert.ErrorAssertionFunc + }{{ + name: "empty", + json: `{}`, + expected: "", + }, { + name: "empty string locale", + json: `{"locale":""}`, + expected: "", + }, { + name: "invalid string locale", + json: `{"locale":"""}`, + assertErr: assert.Error, + }, { + name: "string locale", + json: `{"locale":"en-US"}`, + expected: "en-US", + }, { + name: "linkedin locale", + json: `{"locale":{"country":"US","language":"en","ignore":"me"}}`, + expected: "en-US", + }, { + name: "missing country linkedin locale", + json: `{"locale":{"language":"en"}}`, + expected: "en", + }, { + name: "missing language linkedin locale", + json: `{"locale":{"country":"US"}}`, + expected: "US", + }, { + name: "invalid linkedin locale", + json: `{"locale":{"invalid":"me"}}`, + expected: "", + }} { + t.Run(tc.name, func(t *testing.T) { + var c Claims + err := json.Unmarshal([]byte(tc.json), &c) + if tc.assertErr != nil { + tc.assertErr(t, err) + return + } + require.NoError(t, err) + assert.EqualValues(t, tc.expected, c.Locale) + }) + } +} diff --git a/test/e2e/cypress/integration/profiles/passkey/flows.spec.ts b/test/e2e/cypress/integration/profiles/passkey/flows.spec.ts index 95fafafa0f30..78426b7cd9f4 100644 --- a/test/e2e/cypress/integration/profiles/passkey/flows.spec.ts +++ b/test/e2e/cypress/integration/profiles/passkey/flows.spec.ts @@ -4,7 +4,7 @@ import { gen } from "../../../helpers" import { routes as express } from "../../../helpers/express" import { routes as react } from "../../../helpers/react" -import { testRegistrationWebhook } from "../../../helpers/webhook" +import { testFlowWebhook } from "../../../helpers/webhook" const signup = (registration: string, app: string, email = gen.email()) => { cy.visit(registration) @@ -158,8 +158,12 @@ context("Passkey registration", () => { }) it("should pass transient_payload to webhook", () => { - testRegistrationWebhook( - (hooks) => cy.setupHooks("registration", "after", "passkey", hooks), + testFlowWebhook( + (hooks) => + cy.setupHooks("registration", "after", "passkey", [ + ...hooks, + { hook: "session" }, + ]), () => { signup(registration, app) }, diff --git a/test/e2e/cypress/integration/profiles/two-steps/registration/oidc.spec.ts b/test/e2e/cypress/integration/profiles/two-steps/registration/oidc.spec.ts index 33b8bafe8735..cd4cf069c556 100644 --- a/test/e2e/cypress/integration/profiles/two-steps/registration/oidc.spec.ts +++ b/test/e2e/cypress/integration/profiles/two-steps/registration/oidc.spec.ts @@ -4,7 +4,7 @@ import { appPrefix, gen, website } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" -import { testRegistrationWebhook } from "../../../../helpers/webhook" +import { testFlowWebhook } from "../../../../helpers/webhook" context("Social Sign Up Successes", () => { ;[ @@ -104,8 +104,12 @@ context("Social Sign Up Successes", () => { }) it("should pass transient_payload to webhook", () => { - testRegistrationWebhook( - (hooks) => cy.setupHooks("registration", "after", "oidc", hooks), + testFlowWebhook( + (hooks) => + cy.setupHooks("registration", "after", "oidc", [ + ...hooks, + { hook: "session" }, + ]), () => { const email = gen.email() cy.registerOidc({