mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
684 lines
26 KiB
Go
684 lines
26 KiB
Go
package oidc
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/ory/fosite"
|
|
"github.com/ory/fosite/compose"
|
|
fositejwt "github.com/ory/fosite/token/jwt"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
// TestTokenHandlerClientCredentialsGrant guards two regressions in one flow:
|
|
// - the resource-owner re-validation must be skipped for client_credentials (which has
|
|
// no user / empty subject), otherwise the grant is rejected with invalid_grant; and
|
|
// - every issued JWT access token must carry an `aud` claim bound to the client
|
|
// (RFC 9068 §2.2), which fosite only emits when the granted audience is non-empty.
|
|
//
|
|
// The provider-level tests bypass tokenHandler.token by calling NewAccessResponse directly,
|
|
// so this drives the real HTTP handler with confidential-client Basic auth.
|
|
func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
clientID = "cc-client"
|
|
clientPlain = "cc-secret-value"
|
|
)
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Client Credentials Client",
|
|
Secret: string(hashed),
|
|
IsPublic: false,
|
|
}).Error)
|
|
|
|
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
|
|
|
form := url.Values{"grant_type": {"client_credentials"}}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetBasicAuth(clientID, clientPlain)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
|
require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
|
|
|
|
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
|
// With no resource requested, the client_credentials token is a plain token bound to the requesting client
|
|
require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client")
|
|
}
|
|
|
|
// TestTokenHandlerClientCredentialsDropsIdentityScopes guards that a machine token never
|
|
// carries identity scopes. A client_credentials grant has a synthetic subject and no real
|
|
// user, so if it could keep the openid scope it would slip past the userinfo endpoint
|
|
// (which gates on openid) and probe for user PII. The handler must strip identity scopes.
|
|
func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
clientID = "cc-client"
|
|
clientPlain = "cc-secret-value"
|
|
)
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Client Credentials Client",
|
|
Secret: string(hashed),
|
|
IsPublic: false,
|
|
}).Error)
|
|
|
|
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
|
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"openid"}}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetBasicAuth(clientID, clientPlain)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
|
require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
|
|
|
|
// Introspect the issued token the same way the userinfo endpoint does: openid must be gone.
|
|
_, accessRequest, err := provider.IntrospectToken(t.Context(), body["access_token"].(string), fosite.AccessToken, NewEmptySession())
|
|
require.NoError(t, err)
|
|
require.False(t, accessRequest.GetGrantedScopes().Has("openid"), "client_credentials token must not carry the openid scope")
|
|
}
|
|
|
|
// TestTokenHandlerClientCredentialsUsesClientSubjectGrants guards the user/client grant
|
|
// separation on the machine-to-machine path: a permission granted only for user-delegated
|
|
// access must not be mintable via client_credentials, while a client-subject grant must be.
|
|
func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
clientID = "cc-client"
|
|
clientPlain = "cc-secret-value"
|
|
apiAudience = "https://api.orders.example.com"
|
|
)
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Client Credentials Client",
|
|
Secret: string(hashed),
|
|
IsPublic: false,
|
|
}).Error)
|
|
|
|
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
|
apiAudience: {
|
|
SubjectTypeUser: {"read:orders"},
|
|
SubjectTypeClient: {"write:orders"},
|
|
},
|
|
}}
|
|
|
|
provider, err := newProvider(NewStore(db, apiAccess), nil, testTokenSigner{key: key}, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
|
|
|
requestToken := func(t *testing.T, scope string) map[string]any {
|
|
t.Helper()
|
|
form := url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"scope": {scope},
|
|
"resource": {apiAudience},
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetBasicAuth(clientID, clientPlain)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
|
return body
|
|
}
|
|
|
|
// The client-subject grant is issued and audienced to the API
|
|
body := requestToken(t, "openid write:orders")
|
|
require.NotEmpty(t, body["access_token"], "client-granted scope must be issued, got error: %v (%v)", body["error"], body["error_description"])
|
|
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
|
require.Equal(t, []string{apiAudience}, jwtAudience(claims), "access token must be audience-bound to the API")
|
|
require.Equal(t, []string{"write:orders"}, jwtScopes(claims), "identity scopes must be stripped from machine tokens")
|
|
|
|
// The permission users may delegate is not available to the client itself
|
|
body = requestToken(t, "read:orders")
|
|
require.Empty(t, body["access_token"], "user-delegated permission must not be mintable machine-to-machine")
|
|
require.Equal(t, "invalid_scope", body["error"])
|
|
}
|
|
|
|
func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
clientID = "cc-client"
|
|
clientPlain = "cc-secret-value"
|
|
apiAudience = "https://api.orders.example.com"
|
|
)
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Client Credentials Client",
|
|
Secret: string(hashed),
|
|
IsPublic: false,
|
|
}).Error)
|
|
|
|
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
|
apiAudience: {
|
|
SubjectTypeUser: {"read:profile"},
|
|
SubjectTypeClient: {"read:orders", "write:orders"},
|
|
},
|
|
}}
|
|
|
|
provider, err := newProvider(NewStore(db, apiAccess), nil, testTokenSigner{key: key}, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
|
|
|
requestToken := func(t *testing.T, target string, form url.Values) map[string]any {
|
|
t.Helper()
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, target, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetBasicAuth(clientID, clientPlain)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
|
require.NotEmpty(t, body["access_token"], "client credentials request must issue a token, got error: %v (%v)", body["error"], body["error_description"])
|
|
return body
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
target string
|
|
form url.Values
|
|
}{
|
|
{
|
|
name: "resource parameter",
|
|
target: "/api/oidc/token",
|
|
form: url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"resource": {apiAudience},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := requestToken(t, tc.target, tc.form)
|
|
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
|
require.Equal(t, []string{apiAudience}, jwtAudience(claims), "access token must be audience-bound to the API")
|
|
require.Equal(t, []string{"read:orders", "write:orders"}, jwtScopes(claims), "all client-subject API scopes must be granted by default")
|
|
})
|
|
}
|
|
}
|
|
|
|
// jwtAudience normalizes the `aud` claim (string or []string) into a slice.
|
|
func jwtAudience(claims map[string]any) []string {
|
|
switch aud := claims["aud"].(type) {
|
|
case string:
|
|
return []string{aud}
|
|
case []any:
|
|
out := make([]string, 0, len(aud))
|
|
for _, a := range aud {
|
|
if s, ok := a.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// jwtScopes extracts the scope claim from a decoded access token JWT as a sorted slice
|
|
// It reads the RFC 9068 `scp` list claim and falls back to the space-delimited `scope` string
|
|
func jwtScopes(claims map[string]any) []string {
|
|
var out []string
|
|
scp, ok := claims["scp"].([]any)
|
|
if ok {
|
|
for _, s := range scp {
|
|
str, ok := s.(string)
|
|
if ok {
|
|
out = append(out, str)
|
|
}
|
|
}
|
|
}
|
|
|
|
if out == nil {
|
|
scope, ok := claims["scope"].(string)
|
|
if ok && scope != "" {
|
|
out = strings.Fields(scope)
|
|
}
|
|
}
|
|
|
|
sort.Strings(out)
|
|
|
|
return out
|
|
}
|
|
|
|
// TestTokenHandlerRefreshGrantRevalidatesUser is the regression guard for the most
|
|
// security-sensitive part of the fosite migration: fosite's refresh-token grant replays
|
|
// the stored session without reloading the user, so the token handler must re-check the
|
|
// resource owner on every refresh. Without that re-check, a user who is disabled or
|
|
// removed from a group-restricted client after the initial login keeps minting fresh
|
|
// access/ID tokens (and rotating the refresh token) until the 30-day refresh token
|
|
// expires, defeating offboarding and incident response. This drives the real refresh grant
|
|
// end to end through the HTTP handler.
|
|
func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
)
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
signer := testTokenSigner{key: key}
|
|
|
|
// mintRefreshToken stores an active refresh-token session for the user/client pair and
|
|
// returns the opaque token. It mirrors how the e2e test service seeds refresh tokens:
|
|
// the HMAC signature is derived from the same global secret the provider uses, so the
|
|
// real refresh grant resolves it.
|
|
mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string) string {
|
|
t.Helper()
|
|
globalSecret, err := DeriveGlobalSecret([]byte(secret))
|
|
require.NoError(t, err)
|
|
strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
|
|
GlobalSecret: globalSecret,
|
|
RefreshTokenLifespan: 30 * 24 * time.Hour,
|
|
})
|
|
token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now().UTC()
|
|
session := NewEmptySession()
|
|
session.Subject = userID
|
|
session.Claims = &fositejwt.IDTokenClaims{
|
|
Subject: userID,
|
|
RequestedAt: now,
|
|
AuthTime: now,
|
|
Extra: map[string]any{},
|
|
}
|
|
session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour))
|
|
session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour))
|
|
|
|
request := fosite.NewRequest()
|
|
request.ID = "refresh-req-" + userID
|
|
request.RequestedAt = now
|
|
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}}
|
|
request.RequestedScope = fosite.Arguments{"openid"}
|
|
request.GrantedScope = fosite.Arguments{"openid"}
|
|
request.RequestedAudience = fosite.Arguments{clientID}
|
|
request.GrantedAudience = fosite.Arguments{clientID}
|
|
request.Session = session
|
|
|
|
require.NoError(t, NewStore(db, nil).CreateRefreshTokenSession(t.Context(), signature, "", request))
|
|
return token
|
|
}
|
|
|
|
doRefresh := func(t *testing.T, db *gorm.DB, clientID, refreshToken string) map[string]any {
|
|
t.Helper()
|
|
provider, err := newProvider(NewStore(db, nil), nil, signer, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
|
|
|
form := url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {refreshToken},
|
|
"client_id": {clientID},
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
|
return body
|
|
}
|
|
|
|
createClient := func(t *testing.T, db *gorm.DB, client model.OidcClient) {
|
|
t.Helper()
|
|
require.NoError(t, db.Create(&client).Error)
|
|
}
|
|
|
|
t.Run("enabled user receives rotated tokens", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-ok", "user-ok"
|
|
createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID)
|
|
body := doRefresh(t, db, clientID, token)
|
|
|
|
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
|
require.NotEmpty(t, body["refresh_token"])
|
|
require.NotEqual(t, token, body["refresh_token"], "refresh token must be rotated")
|
|
})
|
|
|
|
t.Run("disabled user is rejected on refresh", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-disabled", "user-disabled"
|
|
createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim", Disabled: true}).Error)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID)
|
|
body := doRefresh(t, db, clientID, token)
|
|
|
|
require.Empty(t, body["access_token"])
|
|
require.Equal(t, "invalid_grant", body["error"])
|
|
})
|
|
|
|
t.Run("user removed from a group-restricted client is rejected on refresh", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-restricted", "user-outsider"
|
|
group := model.UserGroup{Base: model.Base{ID: "allowed-group"}, Name: "allowed", FriendlyName: "Allowed"}
|
|
require.NoError(t, db.Create(&group).Error)
|
|
createClient(t, db, model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Restricted",
|
|
IsPublic: true,
|
|
IsGroupRestricted: true,
|
|
AllowedUserGroups: []model.UserGroup{group},
|
|
})
|
|
// User is not a member of the allowed group.
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "outsider"}).Error)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID)
|
|
body := doRefresh(t, db, clientID, token)
|
|
|
|
require.Empty(t, body["access_token"])
|
|
require.Equal(t, "access_denied", body["error"])
|
|
})
|
|
}
|
|
|
|
func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
const (
|
|
baseURL = "https://issuer.example.com"
|
|
secret = "test-secret"
|
|
apiResource = "https://api.orders.example.com"
|
|
)
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
signer := testTokenSigner{key: key}
|
|
|
|
// grantedAPI is a client that has been granted read:orders on the Orders API
|
|
grantedAPI := userAccess(map[string][]string{apiResource: {"read:orders"}})
|
|
// revokedAPI stands in for the same client after its API grant was removed: no APIs, no scopes
|
|
revokedAPI := userAccess(map[string][]string{})
|
|
|
|
// mintRefreshToken stores an active refresh-token session with the given granted scope and audience, standing in for a token issued by an earlier authorize, and returns the opaque token
|
|
mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string, grantedScope, grantedAudience fosite.Arguments) string {
|
|
t.Helper()
|
|
globalSecret, err := DeriveGlobalSecret([]byte(secret))
|
|
require.NoError(t, err)
|
|
strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
|
|
GlobalSecret: globalSecret,
|
|
RefreshTokenLifespan: 30 * 24 * time.Hour,
|
|
})
|
|
token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now().UTC()
|
|
session := NewEmptySession()
|
|
session.Subject = userID
|
|
session.Claims = &fositejwt.IDTokenClaims{
|
|
Subject: userID,
|
|
RequestedAt: now,
|
|
AuthTime: now,
|
|
Extra: map[string]any{},
|
|
}
|
|
session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour))
|
|
session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour))
|
|
|
|
request := fosite.NewRequest()
|
|
request.ID = "refresh-req-" + userID
|
|
request.RequestedAt = now
|
|
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}}
|
|
request.RequestedScope = grantedScope
|
|
request.GrantedScope = grantedScope
|
|
request.RequestedAudience = grantedAudience
|
|
request.GrantedAudience = grantedAudience
|
|
request.Session = session
|
|
|
|
err = NewStore(db, nil).CreateRefreshTokenSession(t.Context(), signature, "", request)
|
|
require.NoError(t, err)
|
|
|
|
return token
|
|
}
|
|
|
|
// doRefresh runs the refresh grant through the real HTTP handler; apiAccess widens the client's
|
|
// allowed scopes and audiences the same way the api module does in production, and extra merges
|
|
// additional form parameters (such as a widened scope) over the base refresh request
|
|
doRefresh := func(t *testing.T, db *gorm.DB, apiAccess APIAccessProvider, clientID, refreshToken string, extra url.Values) map[string]any {
|
|
t.Helper()
|
|
provider, err := newProvider(NewStore(db, apiAccess), nil, signer, Config{
|
|
BaseURL: baseURL,
|
|
TokenBaseURL: baseURL,
|
|
Secret: []byte(secret),
|
|
})
|
|
require.NoError(t, err)
|
|
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
|
|
|
form := url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {refreshToken},
|
|
"client_id": {clientID},
|
|
}
|
|
maps.Copy(form, extra)
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = req
|
|
handler.token(c)
|
|
|
|
var body map[string]any
|
|
err = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
require.NoError(t, err)
|
|
|
|
return body
|
|
}
|
|
|
|
seedUserAndClient := func(t *testing.T, db *gorm.DB, clientID, userID string) {
|
|
t.Helper()
|
|
rErr := db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true}).Error
|
|
require.NoError(t, rErr)
|
|
rErr = db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error
|
|
require.NoError(t, rErr)
|
|
}
|
|
|
|
t.Run("refresh keeps the API audience and adds the issuer so the token still reaches userinfo", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-api", "user-api"
|
|
seedUserAndClient(t, db, clientID, userID)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID,
|
|
fosite.Arguments{"openid", "read:orders"},
|
|
fosite.Arguments{apiResource},
|
|
)
|
|
body := doRefresh(t, db, grantedAPI, clientID, token, nil)
|
|
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
|
// The grant still carries openid, so the identity side keeps issuing an ID token on refresh
|
|
require.NotEmpty(t, body["id_token"])
|
|
|
|
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
|
// The refreshed access token stays bound to the original API audience and re-adds the issuer so it keeps working at userinfo, never widening to any other API
|
|
require.ElementsMatch(t, []string{apiResource, baseURL}, jwtAudience(claims))
|
|
// A token that requested openid alongside the API keeps the identity scope on the access token, matching what it was granted
|
|
require.Equal(t, []string{"openid", "read:orders"}, jwtScopes(claims))
|
|
})
|
|
|
|
t.Run("refresh cannot upscope beyond the original grant via the scope parameter", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-upscope", "user-upscope"
|
|
seedUserAndClient(t, db, clientID, userID)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID,
|
|
fosite.Arguments{"openid", "read:orders"},
|
|
fosite.Arguments{apiResource},
|
|
)
|
|
// The refresh handler ignores the scope parameter and replays the stored grant, so asking for write:orders is a no-op rather than an escalation
|
|
body := doRefresh(t, db, grantedAPI, clientID, token, url.Values{"scope": {"openid read:orders write:orders"}})
|
|
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
|
|
|
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
|
require.ElementsMatch(t, []string{apiResource, baseURL}, jwtAudience(claims))
|
|
// write:orders never appears on the refreshed token even though it was requested
|
|
require.Equal(t, []string{"openid", "read:orders"}, jwtScopes(claims))
|
|
})
|
|
|
|
t.Run("revoking the client API grant makes the next refresh fail", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-revoked", "user-revoked"
|
|
seedUserAndClient(t, db, clientID, userID)
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID,
|
|
fosite.Arguments{"openid", "read:orders"},
|
|
fosite.Arguments{apiResource},
|
|
)
|
|
// With the grant revoked the client no longer advertises read:orders, so the refresh handler rejects replaying that stored scope
|
|
// Revocation is therefore enforced on the very next refresh rather than lingering until the refresh token expires
|
|
body := doRefresh(t, db, revokedAPI, clientID, token, nil)
|
|
require.Empty(t, body["access_token"])
|
|
require.Equal(t, "invalid_scope", body["error"])
|
|
})
|
|
|
|
t.Run("revoking only the user-delegated API grant makes the next refresh fail", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-user-revoked", "user-user-revoked"
|
|
seedUserAndClient(t, db, clientID, userID)
|
|
|
|
clientOnlyAPI := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
|
apiResource: {
|
|
SubjectTypeClient: {"read:orders"},
|
|
},
|
|
}}
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID,
|
|
fosite.Arguments{"openid", "read:orders"},
|
|
fosite.Arguments{apiResource},
|
|
)
|
|
body := doRefresh(t, db, clientOnlyAPI, clientID, token, nil)
|
|
require.Empty(t, body["access_token"])
|
|
require.Equal(t, "access_denied", body["error"])
|
|
})
|
|
|
|
t.Run("refresh rechecks the granted scope against the granted API audience", func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const clientID, userID = "client-split-grant", "user-split-grant"
|
|
seedUserAndClient(t, db, clientID, userID)
|
|
|
|
splitGrantAPI := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
|
apiResource: {
|
|
SubjectTypeUser: {"write:orders"},
|
|
},
|
|
"https://api.inventory.example.com": {
|
|
SubjectTypeUser: {"read:orders"},
|
|
},
|
|
}}
|
|
|
|
token := mintRefreshToken(t, db, clientID, userID,
|
|
fosite.Arguments{"openid", "read:orders"},
|
|
fosite.Arguments{apiResource},
|
|
)
|
|
body := doRefresh(t, db, splitGrantAPI, clientID, token, nil)
|
|
require.Empty(t, body["access_token"])
|
|
require.Equal(t, "invalid_scope", body["error"])
|
|
})
|
|
}
|