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: linkedin v2 provider #3804

Merged
merged 10 commits into from
Mar 12, 2024
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ heap_profiler/
goroutine_dump/
inflight_trace_dump/

contrib/quickstart/kratos/oidc

e2e/*.log
e2e/kratos.*.yml
e2e/proxy.json
Expand Down
23 changes: 5 additions & 18 deletions driver/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -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()
})
}
})
Expand Down
1 change: 1 addition & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@
"dingtalk",
"patreon",
"linkedin",
"linkedin_v2",
"lark",
"x"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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\":\"\"}]}"
4 changes: 2 additions & 2 deletions identity/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,15 @@ 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 {
h.r.Writer().WriteError(w, r, err)
return
}

isam[i] = WithCredentialsMetadataAndAdminMetadataInJSON(*emit)
isam[i] = WithCredentialsAndAdminMetadataInJSON(*emit)
}

h.r.Writer().Write(w, r, isam)
Expand Down
14 changes: 9 additions & 5 deletions identity/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions internal/client-go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
27 changes: 26 additions & 1 deletion selfservice/strategy/oidc/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package oidc

import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"

"github.com/dghubble/oauth1"
"github.com/pkg/errors"
Expand Down Expand Up @@ -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"`
Expand All @@ -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 == "" {
Expand Down
41 changes: 21 additions & 20 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/provider_discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
Picture: user.AvatarURL(""),
Email: user.Email,
EmailVerified: x.ConvertibleBoolean(user.Verified),
Locale: user.Locale,
Locale: Locale(user.Locale),

Check warning on line 96 in selfservice/strategy/oidc/provider_discord.go

View check run for this annotation

Codecov / codecov/patch

selfservice/strategy/oidc/provider_discord.go#L96

Added line #L96 was not covered by tests
}

return claims, nil
Expand Down
47 changes: 47 additions & 0 deletions selfservice/strategy/oidc/provider_linkedin_v2.go
Original file line number Diff line number Diff line change
@@ -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")
hperl marked this conversation as resolved.
Show resolved Hide resolved
}

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)

Check warning on line 46 in selfservice/strategy/oidc/provider_linkedin_v2.go

View check run for this annotation

Codecov / codecov/patch

selfservice/strategy/oidc/provider_linkedin_v2.go#L45-L46

Added lines #L45 - L46 were not covered by tests
}
34 changes: 34 additions & 0 deletions selfservice/strategy/oidc/provider_linkedin_v2_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions selfservice/strategy/oidc/provider_private_net_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 55 additions & 1 deletion selfservice/strategy/oidc/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -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")
}
Expand All @@ -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)
})
}
}
Loading
Loading