mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
735 lines
30 KiB
Go
735 lines
30 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ory/fosite"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
func TestBuildClientFromMetadata(t *testing.T) {
|
|
const id = "https://app.example.com/oauth/client"
|
|
|
|
t.Run("public client maps to PKCE", func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
ClientName: "Example App",
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
PostLogoutRedirectURIs: []string{"https://app.example.com/logout"},
|
|
TokenEndpointAuthMethod: "none",
|
|
}
|
|
c, err := buildClientFromMetadata(doc, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, id, c.ID)
|
|
assert.Equal(t, "Example App", c.Name)
|
|
assert.True(t, c.IsPublic)
|
|
assert.True(t, c.PkceEnabled)
|
|
assert.True(t, c.IsMetadataDocument())
|
|
assert.Equal(t, []string{"https://app.example.com/callback"}, []string(c.CallbackURLs))
|
|
assert.Equal(t, []string{"https://app.example.com/logout"}, []string(c.LogoutCallbackURLs))
|
|
assert.Equal(t, []string{"authorization_code"}, []string(c.MetadataGrantTypes))
|
|
assert.Empty(t, c.Credentials.FederatedIdentities)
|
|
assert.Equal(t, model.DefaultAccessTokenDurationMinutes, c.AccessTokenDurationMinutes)
|
|
assert.Equal(t, model.DefaultRefreshTokenDurationMinutes, c.RefreshTokenDurationMinutes)
|
|
})
|
|
|
|
t.Run("authenticated clients are rejected", func(t *testing.T) {
|
|
for _, m := range []string{"private_key_jwt", "client_secret_basic", "client_secret_post", "client_secret_jwt"} { //nolint:gosec // G101 false positive: authentication method names, not credentials
|
|
doc := &fosite.ClientMetadataDocument{ClientID: id, TokenEndpointAuthMethod: m}
|
|
_, err := buildClientFromMetadata(doc, id)
|
|
require.Errorf(t, err, "method %q", m)
|
|
}
|
|
})
|
|
|
|
t.Run("unsupported grant types are dropped", func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
TokenEndpointAuthMethod: "none",
|
|
GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
|
}
|
|
c, err := buildClientFromMetadata(doc, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"authorization_code", "refresh_token"}, []string(c.MetadataGrantTypes))
|
|
})
|
|
|
|
t.Run("documents without a supported initiating grant are rejected", func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
TokenEndpointAuthMethod: "none",
|
|
GrantTypes: []string{"refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
|
}
|
|
_, err := buildClientFromMetadata(doc, id)
|
|
require.ErrorIs(t, err, errInitiatingGrantRequired)
|
|
})
|
|
|
|
t.Run("name falls back to the client ID host", func(t *testing.T) {
|
|
c, err := buildClientFromMetadata(&fosite.ClientMetadataDocument{ClientID: id, TokenEndpointAuthMethod: "none"}, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "app.example.com", c.Name)
|
|
})
|
|
}
|
|
|
|
func TestCIMDPolicyValidate(t *testing.T) {
|
|
policy := cimdPolicy{}
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
grantTypes []string
|
|
responseTypes []string
|
|
wantError string
|
|
}{
|
|
{name: "defaults are supported"},
|
|
{name: "authorization code and refresh token are supported", grantTypes: []string{"authorization_code", "refresh_token"}},
|
|
{name: "device code is supported", grantTypes: []string{string(fosite.GrantTypeDeviceCode)}},
|
|
{name: "unsupported grant types are ignored", grantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"}},
|
|
{name: "client credentials cannot initiate authorization", grantTypes: []string{"client_credentials"}, wantError: "must enable"},
|
|
{name: "refresh token cannot initiate authorization", grantTypes: []string{"refresh_token"}, wantError: "must enable"},
|
|
{name: "implicit response is rejected", grantTypes: []string{"authorization_code"}, responseTypes: []string{"token"}, wantError: "unsupported response_type"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := policy.ValidateCIMDClient(t.Context(), &fosite.ClientMetadataDocument{
|
|
TokenEndpointAuthMethod: "none",
|
|
GrantTypes: test.grantTypes,
|
|
ResponseTypes: test.responseTypes,
|
|
})
|
|
if test.wantError == "" {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
require.ErrorContains(t, err, test.wantError)
|
|
})
|
|
}
|
|
|
|
t.Run("omitted authentication method is rejected", func(t *testing.T) {
|
|
err := policy.ValidateCIMDClient(t.Context(), &fosite.ClientMetadataDocument{})
|
|
require.ErrorContains(t, err, "token_endpoint_auth_method")
|
|
})
|
|
}
|
|
|
|
func TestMetadataClientChanges(t *testing.T) {
|
|
base := model.OidcClient{
|
|
Name: "App",
|
|
CallbackURLs: datatype.StringList{"https://app/cb"},
|
|
LogoutCallbackURLs: datatype.StringList{"https://app/lo"},
|
|
IsPublic: true,
|
|
}
|
|
|
|
t.Run("no changes", func(t *testing.T) {
|
|
assert.Empty(t, metadataClientChanges(base, base))
|
|
})
|
|
|
|
t.Run("redirect_uris change", func(t *testing.T) {
|
|
next := base
|
|
next.CallbackURLs = datatype.StringList{"https://app/other"}
|
|
assert.Equal(t, []string{"redirect_uris"}, metadataClientChanges(base, next))
|
|
})
|
|
|
|
t.Run("auth method change", func(t *testing.T) {
|
|
next := base
|
|
next.IsPublic = false
|
|
got := metadataClientChanges(base, next)
|
|
assert.Contains(t, got, "token_endpoint_auth_method")
|
|
})
|
|
|
|
t.Run("grant types change", func(t *testing.T) {
|
|
next := base
|
|
next.MetadataGrantTypes = datatype.StringList{"authorization_code", "refresh_token"}
|
|
assert.Contains(t, metadataClientChanges(base, next), "grant_types")
|
|
})
|
|
}
|
|
|
|
func TestRefreshMetadataClient(t *testing.T) {
|
|
const id = "https://8.8.8.8/oauth/client"
|
|
body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}`
|
|
|
|
t.Run("empty allowlist", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil, func() []string { return nil })
|
|
_, err := s.RefreshMetadataClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("non-URL id", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
_, err := s.RefreshMetadataClient(t.Context(), "not-a-url")
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("unknown client yields not found", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
_, err := s.RefreshMetadataClient(t.Context(), id)
|
|
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
})
|
|
|
|
t.Run("non-metadata client is rejected", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
seed := model.OidcClient{Base: model.Base{ID: id}, Name: "Standard"}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
_, err := s.RefreshMetadataClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
require.NotErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
})
|
|
|
|
t.Run("forces re-fetch even when cache is fresh", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
fresh := datatype.DateTime(time.Now().Add(time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id},
|
|
Name: "Old",
|
|
IsPublic: true,
|
|
PkceEnabled: true,
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
MetadataExpiresAt: &fresh,
|
|
AccessTokenDurationMinutes: 2 * 60,
|
|
RefreshTokenDurationMinutes: 7 * 24 * 60,
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
|
|
// A normal lookup still returns the cached value.
|
|
fc, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Old", fc.(Client).Name)
|
|
|
|
// A forced refresh re-fetches and updates the cached client.
|
|
c, err := s.RefreshMetadataClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "App", c.Name)
|
|
assert.True(t, c.IsMetadataDocument())
|
|
assert.Equal(t, int64(2*60), c.AccessTokenDurationMinutes)
|
|
assert.Equal(t, int64(7*24*60), c.RefreshTokenDurationMinutes)
|
|
})
|
|
}
|
|
|
|
type metadataStore struct {
|
|
*Store
|
|
resolver *cimdClientResolver
|
|
}
|
|
|
|
func (s *metadataStore) GetClient(ctx context.Context, id string) (fosite.Client, error) {
|
|
client, err := s.resolver.ResolveClient(ctx, id, s.Store.GetClient)
|
|
if err == nil {
|
|
return client, nil
|
|
}
|
|
if errors.Is(err, fosite.ErrNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
if fosite.LooksLikeCIMDURL(id) {
|
|
return nil, fosite.ErrInvalidClient.WithHint("The client metadata document could not be resolved.").WithWrap(err).WithDebug(err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
func (s *metadataStore) RefreshMetadataClient(ctx context.Context, id string) (model.OidcClient, error) {
|
|
return s.resolver.RefreshMetadataClient(ctx, id)
|
|
}
|
|
|
|
func newMetadataStore(t *testing.T, responses map[string]*http.Response, allowlists ...func() []string) *metadataStore {
|
|
t.Helper()
|
|
getAllowlist := func() []string { return []string{"*"} }
|
|
if len(allowlists) > 0 {
|
|
getAllowlist = allowlists[0]
|
|
}
|
|
store := NewStore(testutils.NewDatabaseForTest(t), nil)
|
|
return &metadataStore{
|
|
Store: store,
|
|
resolver: newCIMDClientResolver(store, cimdResolverConfig{
|
|
getURLAllowlist: getAllowlist,
|
|
transport: &testutils.MockRoundTripper{Responses: responses},
|
|
}),
|
|
}
|
|
}
|
|
|
|
func TestGetClient_CIMDURLAllowlist(t *testing.T) {
|
|
const id = "https://8.8.8.8/oauth/client"
|
|
body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}`
|
|
|
|
t.Run("empty allowlist denies without fetching", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return nil },
|
|
)
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Equal(t, int64(0), count)
|
|
})
|
|
|
|
t.Run("non-matching allowlist denies", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return []string{"https://other.example.com/**"} },
|
|
)
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
})
|
|
|
|
t.Run("matching allowlist allows", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return []string{"https://8.8.8.8/**"} },
|
|
)
|
|
fc, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, id, fc.(Client).ID)
|
|
})
|
|
|
|
t.Run("refresh denied when not allowlisted", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return nil },
|
|
)
|
|
_, err := s.RefreshMetadataClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGetClient_MetadataDocument(t *testing.T) {
|
|
const id = "https://8.8.8.8/oauth/client"
|
|
body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}`
|
|
|
|
t.Run("non-URL id falls through to the database", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
_, err := s.GetClient(t.Context(), "does-not-exist")
|
|
require.ErrorIs(t, err, fosite.ErrNotFound)
|
|
})
|
|
|
|
t.Run("allowlist changes apply without rebuilding the store", func(t *testing.T) {
|
|
allowlist := []string(nil)
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return allowlist },
|
|
)
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
|
|
allowlist = []string{"https://8.8.8.8/**"}
|
|
fc, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, id, fc.(Client).ID)
|
|
|
|
allowlist = nil
|
|
_, err = s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
})
|
|
|
|
t.Run("pre-registered URL client takes precedence", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp},
|
|
func() []string { return nil },
|
|
)
|
|
seed := model.OidcClient{Base: model.Base{ID: id}, Name: "Standard"}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
|
|
resolved, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Standard", resolved.(Client).Name)
|
|
|
|
stored, err := s.firstClientByID(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Standard", stored.Name)
|
|
assert.False(t, stored.IsMetadataDocument())
|
|
})
|
|
|
|
t.Run("no-store metadata is rejected and not persisted", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
resp.Header.Set("Cache-Control", "no-store")
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
})
|
|
|
|
t.Run("private_key_jwt metadata is rejected and not persisted", func(t *testing.T) {
|
|
privateKeyBody := `{"client_id":"https://8.8.8.8/oauth/client","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"private_key_jwt","jwks_uri":"https://8.8.4.4/jwks"}` //nolint:gosec // G101 false positive: authentication method name, not a credential
|
|
resp := testutils.NewMockResponse(http.StatusOK, privateKeyBody) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
})
|
|
|
|
t.Run("cached authenticated metadata client is rejected", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
fresh := datatype.DateTime(time.Now().Add(time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id},
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
MetadataExpiresAt: &fresh,
|
|
Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
|
Issuer: id,
|
|
JWKS: "https://8.8.4.4/jwks",
|
|
}}},
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
})
|
|
|
|
t.Run("cached authenticated metadata client is replaced when the document becomes public", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
fresh := datatype.DateTime(time.Now().Add(time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id},
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
MetadataExpiresAt: &fresh,
|
|
Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{
|
|
Issuer: id,
|
|
JWKS: "https://8.8.4.4/jwks",
|
|
}}},
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
|
|
resolved, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
client := resolved.(Client)
|
|
assert.True(t, client.OidcClient.IsPublic)
|
|
assert.True(t, client.PkceEnabled)
|
|
assert.Empty(t, client.Credentials.FederatedIdentities)
|
|
})
|
|
|
|
t.Run("fetches, upserts, and reuses the cache", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
resp.Header.Set("Cache-Control", "max-age=600")
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
fc, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
c := fc.(Client).OidcClient
|
|
assert.Equal(t, id, c.ID)
|
|
assert.True(t, c.IsMetadataDocument())
|
|
assert.True(t, c.IsPublic)
|
|
require.NotNil(t, c.MetadataExpiresAt)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Equal(t, int64(1), count)
|
|
|
|
fc2, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "App", fc2.(Client).Name)
|
|
})
|
|
|
|
// A display-only change must not cost the user their consent
|
|
t.Run("refetch when stale preserves consent", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
stale := datatype.DateTime(time.Now().Add(-time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id}, Name: "Old", IsPublic: true, PkceEnabled: true,
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
CallbackURLs: datatype.StringList{"https://app/cb"},
|
|
MetadataExpiresAt: &stale,
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
require.NoError(t, s.db.Exec(
|
|
"INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)",
|
|
id, "user-1", "openid", time.Now()).Error)
|
|
|
|
fc, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "App", fc.(Client).Name)
|
|
|
|
var consent int64
|
|
require.NoError(t, s.db.Table("user_authorized_oidc_clients").
|
|
Where("client_id = ?", id).Count(&consent).Error)
|
|
assert.Equal(t, int64(1), consent)
|
|
})
|
|
|
|
// Whoever controls the document could otherwise repoint an already-consented user's authorization code at a URL of their choosing, with no prompt
|
|
t.Run("changed redirect_uris revoke consent", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
stale := datatype.DateTime(time.Now().Add(-time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id}, Name: "App", IsPublic: true, PkceEnabled: true,
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
CallbackURLs: datatype.StringList{"https://app/previous-cb"},
|
|
MetadataExpiresAt: &stale,
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
require.NoError(t, s.db.Exec(
|
|
"INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)",
|
|
id, "user-1", "openid", time.Now()).Error)
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
|
|
var consent int64
|
|
require.NoError(t, s.db.Table("user_authorized_oidc_clients").
|
|
Where("client_id = ?", id).Count(&consent).Error)
|
|
assert.Zero(t, consent, "consent must not survive a redirect_uris change")
|
|
})
|
|
|
|
t.Run("failed consent revocation rolls back refreshed metadata", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
|
|
|
stale := datatype.DateTime(time.Now().Add(-time.Hour))
|
|
seed := model.OidcClient{
|
|
Base: model.Base{ID: id}, Name: "App", IsPublic: true, PkceEnabled: true,
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
CallbackURLs: datatype.StringList{"https://app/previous-cb"},
|
|
MetadataExpiresAt: &stale,
|
|
}
|
|
require.NoError(t, s.db.Create(&seed).Error)
|
|
require.NoError(t, s.db.Exec(
|
|
"INSERT INTO user_authorized_oidc_clients (client_id, user_id, scope, last_used_at) VALUES (?, ?, ?, ?)",
|
|
id, "user-1", "openid", time.Now()).Error)
|
|
require.NoError(t, s.db.Exec(`
|
|
CREATE TRIGGER reject_cimd_consent_delete
|
|
BEFORE DELETE ON user_authorized_oidc_clients
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'consent deletion blocked');
|
|
END;
|
|
`).Error)
|
|
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
|
|
stored, err := s.firstClientByID(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"https://app/previous-cb"}, []string(stored.CallbackURLs))
|
|
var consent int64
|
|
require.NoError(t, s.db.Table("user_authorized_oidc_clients").Where("client_id = ?", id).Count(&consent).Error)
|
|
assert.Equal(t, int64(1), consent)
|
|
})
|
|
}
|
|
|
|
// Pocket ID matches administrator-registered callback URLs as wildcard patterns, so a self-asserted "*" would otherwise match every redirect URI in existence
|
|
func TestBuildClientFromMetadata_RejectsPatternRedirectURIs(t *testing.T) {
|
|
const id = "https://app.example.com/oauth/client"
|
|
|
|
unsupportedByPocketID := []string{
|
|
"*",
|
|
"https://*.example.com/cb",
|
|
"javascript:alert(1)",
|
|
}
|
|
for _, uri := range unsupportedByPocketID {
|
|
t.Run("redirect_uris "+uri, func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{uri},
|
|
TokenEndpointAuthMethod: "none",
|
|
}
|
|
_, err := buildClientFromMetadata(doc, id)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
// post_logout_redirect_uris go through the same validation
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{"https://app.example.com/cb"},
|
|
PostLogoutRedirectURIs: []string{"*"},
|
|
TokenEndpointAuthMethod: "none",
|
|
}
|
|
_, err := buildClientFromMetadata(doc, id)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestMatchRedirectURI_MetadataClientExactMatchSucceeds(t *testing.T) {
|
|
metadataClient := Client{OidcClient: model.OidcClient{
|
|
Base: model.Base{ID: "https://app.example.com/oauth/client"},
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
CallbackURLs: datatype.StringList{"https://app.example.com/callback"},
|
|
}}
|
|
|
|
matched, err := matchRedirectURI("https://app.example.com/callback", metadataClient)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, matched)
|
|
assert.Equal(t, "https://app.example.com/callback", matched.String())
|
|
}
|
|
|
|
// A document declaring only authorization_code must not silently receive refresh_token and device_code
|
|
func TestClient_DeclaredCapabilitiesAreEnforced(t *testing.T) {
|
|
t.Run("grant types are restricted to the declaration", func(t *testing.T) {
|
|
client := Client{OidcClient: model.OidcClient{
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
IsPublic: true,
|
|
MetadataGrantTypes: datatype.StringList{"authorization_code"},
|
|
}}
|
|
assert.Equal(t, fosite.Arguments{"authorization_code"}, client.GetGrantTypes())
|
|
})
|
|
|
|
t.Run("declared refresh_token is honoured", func(t *testing.T) {
|
|
client := Client{OidcClient: model.OidcClient{
|
|
ClientType: model.OidcClientTypeCIMD,
|
|
IsPublic: true,
|
|
MetadataGrantTypes: datatype.StringList{"authorization_code", "refresh_token"},
|
|
}}
|
|
assert.Equal(t, fosite.Arguments{"authorization_code", "refresh_token"}, client.GetGrantTypes())
|
|
})
|
|
|
|
t.Run("an empty declaration uses the RFC default", func(t *testing.T) {
|
|
client := Client{OidcClient: model.OidcClient{ClientType: model.OidcClientTypeCIMD, IsPublic: true}}
|
|
assert.Equal(t, fosite.Arguments{"authorization_code"}, client.GetGrantTypes())
|
|
})
|
|
|
|
t.Run("registered clients are unaffected", func(t *testing.T) {
|
|
client := Client{OidcClient: model.OidcClient{
|
|
ClientType: model.OidcClientTypeStandard,
|
|
IsPublic: true,
|
|
MetadataGrantTypes: datatype.StringList{"authorization_code"},
|
|
}}
|
|
assert.Contains(t, client.GetGrantTypes(), "refresh_token")
|
|
})
|
|
}
|
|
|
|
func TestBuildClientFromMetadata_RecordsDeclaredCapabilities(t *testing.T) {
|
|
const id = "https://app.example.com/oauth/client"
|
|
|
|
t.Run("declared values are recorded", func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{"https://app.example.com/cb"},
|
|
TokenEndpointAuthMethod: "none",
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
}
|
|
client, err := buildClientFromMetadata(doc, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, datatype.StringList{"authorization_code", "refresh_token"}, client.MetadataGrantTypes)
|
|
})
|
|
|
|
t.Run("omitted grant_types defaults to authorization_code", func(t *testing.T) {
|
|
doc := &fosite.ClientMetadataDocument{
|
|
ClientID: id,
|
|
RedirectURIs: []string{"https://app.example.com/cb"},
|
|
TokenEndpointAuthMethod: "none",
|
|
}
|
|
client, err := buildClientFromMetadata(doc, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, datatype.StringList{"authorization_code"}, client.MetadataGrantTypes)
|
|
})
|
|
}
|
|
|
|
// The allowlist is the operator's only gate on which URLs may become clients, and it is matched with the same wildcard syntax as callback URLs
|
|
func TestCIMDURLAllowlist_HostilePatterns(t *testing.T) {
|
|
const id = "https://8.8.8.8/oauth/client"
|
|
body := `{"client_id":"https://8.8.8.8/oauth/client","client_name":"App","redirect_uris":["https://app/cb"],"token_endpoint_auth_method":"none"}`
|
|
|
|
denied := []struct {
|
|
name string
|
|
allowlist []string
|
|
}{
|
|
{"empty list denies", nil},
|
|
{"different host denies", []string{"https://other.example.com/**"}},
|
|
{"different scheme denies", []string{"http://8.8.8.8/**"}},
|
|
{"host as a path segment denies", []string{"https://evil.example/8.8.8.8/**"}},
|
|
{"prefix of the host denies", []string{"https://8.8.8.8.evil.example/**"}},
|
|
}
|
|
for _, tc := range denied {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return tc.allowlist })
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidClient)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Zero(t, count, "a denied client must never be persisted")
|
|
})
|
|
}
|
|
|
|
// "*" matches everything, which is the fully open configuration and the operator's choice rather than a bug
|
|
t.Run("bare wildcard allows everything", func(t *testing.T) {
|
|
resp := testutils.NewMockResponse(http.StatusOK, body) //nolint:bodyclose // mock response, no real body
|
|
s := newMetadataStore(t, map[string]*http.Response{id: resp}, func() []string { return []string{"*"} })
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
// Section 3 constrains the Client Identifier URL, and none of these vectors may reach a fetch
|
|
func TestGetClient_HostileClientIDURLs(t *testing.T) {
|
|
hostile := []string{
|
|
"http://8.8.8.8/oauth/client", // not https
|
|
"https://8.8.8.8", // no path component
|
|
"https://user:pass@8.8.8.8/oauth/client", // userinfo
|
|
"https://8.8.8.8/oauth/client#frag", // fragment
|
|
"https://8.8.8.8/oauth/client?x=1", // query component
|
|
"https://8.8.8.8/oauth/../client", // dot segments
|
|
"https://127.0.0.1/oauth/client", // loopback
|
|
"https://169.254.169.254/latest/meta-data", // cloud metadata
|
|
}
|
|
|
|
for _, id := range hostile {
|
|
t.Run(id, func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.Error(t, err, "must not be accepted as a Client Identifier URL")
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Section 5.2 forbids caching error responses or invalid documents, and section 5 requires every non-200 status to be treated as an error
|
|
func TestGetClient_MetadataFailuresAreNotPersisted(t *testing.T) {
|
|
const id = "https://8.8.8.8/oauth/client"
|
|
|
|
cases := []struct {
|
|
name string
|
|
response *http.Response
|
|
}{
|
|
{"404", testutils.NewMockResponse(http.StatusNotFound, `{}`)}, //nolint:bodyclose // mock
|
|
{"500", testutils.NewMockResponse(http.StatusInternalServerError, `{}`)}, //nolint:bodyclose // mock
|
|
{"204", testutils.NewMockResponse(http.StatusNoContent, ``)}, //nolint:bodyclose // mock
|
|
{"invalid JSON", testutils.NewMockResponse(http.StatusOK, `{not json`)}, //nolint:bodyclose // mock
|
|
{"truncated JSON", testutils.NewMockResponse(http.StatusOK, `{"client_id":`)}, //nolint:bodyclose // mock
|
|
{"empty body", testutils.NewMockResponse(http.StatusOK, ``)}, //nolint:bodyclose // mock
|
|
{"null body", testutils.NewMockResponse(http.StatusOK, `null`)}, //nolint:bodyclose // mock
|
|
{"client_id mismatch", testutils.NewMockResponse(http.StatusOK, `{"client_id":"https://evil/x"}`)}, //nolint:bodyclose // mock
|
|
{"wrong client_id type", testutils.NewMockResponse(http.StatusOK, `{"client_id":123}`)}, //nolint:bodyclose // mock
|
|
{"oversize document", testutils.NewMockResponse(http.StatusOK, `{"client_id":"https://8.8.8.8/oauth/client","padding":"`+strings.Repeat("a", 6*1024)+`"}`)}, //nolint:bodyclose // mock
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := newMetadataStore(t, map[string]*http.Response{id: tc.response})
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
|
|
var count int64
|
|
require.NoError(t, s.db.Model(&model.OidcClient{}).Where("id = ?", id).Count(&count).Error)
|
|
assert.Zero(t, count, "a failed or invalid document must never be cached")
|
|
})
|
|
}
|
|
|
|
t.Run("no response at all", func(t *testing.T) {
|
|
s := newMetadataStore(t, nil)
|
|
_, err := s.GetClient(t.Context(), id)
|
|
require.Error(t, err)
|
|
})
|
|
}
|