feat: client_credentials flow support (#901)

This commit is contained in:
Savely Krasovsky
2025-09-03 01:33:01 +02:00
committed by GitHub
parent 0b381467ca
commit 901333f7e4
4 changed files with 164 additions and 6 deletions

View File

@@ -83,7 +83,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},

View File

@@ -87,6 +87,7 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
}
type OidcIntrospectDto struct {

View File

@@ -37,9 +37,11 @@ const (
GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
GrantTypeClientCredentials = "client_credentials"
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
AccessTokenDuration = time.Hour
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
)
@@ -247,6 +249,8 @@ func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateToke
return s.createTokenFromRefreshToken(ctx, input)
case GrantTypeDeviceCode:
return s.createTokenFromDeviceCode(ctx, input)
case GrantTypeClientCredentials:
return s.createTokenFromClientCredentials(ctx, input)
default:
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
}
@@ -329,7 +333,35 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
ExpiresIn: AccessTokenDuration,
}, nil
}
func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false)
if err != nil {
return CreatedTokens{}, err
}
// GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations
// into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations
dummyUser := model.User{
Base: model.Base{ID: "client-" + client.ID},
}
audClaim := client.ID
if input.Resource != "" {
audClaim = input.Resource
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
if err != nil {
return CreatedTokens{}, err
}
return CreatedTokens{
AccessToken: accessToken,
ExpiresIn: AccessTokenDuration,
}, nil
}
@@ -403,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
ExpiresIn: AccessTokenDuration,
}, nil
}
@@ -488,7 +520,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresIn: time.Hour,
ExpiresIn: AccessTokenDuration,
}, nil
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -148,6 +149,13 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
require.NoError(t, err)
// Create a mock config and JwtService to test complete a token creation process
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockJwtService, err := NewJwtService(db, mockConfig)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -162,8 +170,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Init the OidcService
s := &OidcService{
db: db,
httpClient: httpClient,
db: db,
jwtService: mockJwtService,
appConfigService: mockConfig,
httpClient: httpClient,
}
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
@@ -384,4 +394,119 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
assert.Equal(t, federatedClient.ID, client.ID)
})
})
t.Run("Complete token creation flow", func(t *testing.T) {
t.Run("Client Credentials flow", func(t *testing.T) {
t.Run("Succeeds with valid secret", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID")
})
t.Run("Fails with invalid secret", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
})
t.Run("Fails without client secret for public clients", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: publicClient.ID,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
})
t.Run("Succeeds with valid assertion", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClient.ID).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Generate a token
input := dto.OidcCreateTokensDto{
ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
})
t.Run("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
})
t.Run("Succeeds with custom resource", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
Resource: "https://example.com/",
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request")
})
})
})
}