Skip to content

Commit

Permalink
feat: linkedin v2 provider (#3804)
Browse files Browse the repository at this point in the history
* feat: add linkedin-v2 provider

* docs: document linkedin special-case
  • Loading branch information
hperl committed Mar 12, 2024
1 parent 49e1a39 commit a6ad983
Show file tree
Hide file tree
Showing 16 changed files with 220 additions and 54 deletions.
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 @@ 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
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")
}

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)
}
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

0 comments on commit a6ad983

Please sign in to comment.