mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 17:24:48 +00:00
feat: client_credentials flow support (#901)
This commit is contained in:
@@ -83,7 +83,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
|||||||
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
|
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
|
||||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||||
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
|
"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"},
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type OidcCreateTokensDto struct {
|
|||||||
RefreshToken string `form:"refresh_token"`
|
RefreshToken string `form:"refresh_token"`
|
||||||
ClientAssertion string `form:"client_assertion"`
|
ClientAssertion string `form:"client_assertion"`
|
||||||
ClientAssertionType string `form:"client_assertion_type"`
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
|
Resource string `form:"resource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIntrospectDto struct {
|
type OidcIntrospectDto struct {
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ const (
|
|||||||
GrantTypeAuthorizationCode = "authorization_code"
|
GrantTypeAuthorizationCode = "authorization_code"
|
||||||
GrantTypeRefreshToken = "refresh_token"
|
GrantTypeRefreshToken = "refresh_token"
|
||||||
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
||||||
|
GrantTypeClientCredentials = "client_credentials"
|
||||||
|
|
||||||
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
|
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
|
||||||
|
|
||||||
|
AccessTokenDuration = time.Hour
|
||||||
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
||||||
DeviceCodeDuration = 15 * time.Minute
|
DeviceCodeDuration = 15 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -247,6 +249,8 @@ func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateToke
|
|||||||
return s.createTokenFromRefreshToken(ctx, input)
|
return s.createTokenFromRefreshToken(ctx, input)
|
||||||
case GrantTypeDeviceCode:
|
case GrantTypeDeviceCode:
|
||||||
return s.createTokenFromDeviceCode(ctx, input)
|
return s.createTokenFromDeviceCode(ctx, input)
|
||||||
|
case GrantTypeClientCredentials:
|
||||||
|
return s.createTokenFromClientCredentials(ctx, input)
|
||||||
default:
|
default:
|
||||||
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
|
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
@@ -329,7 +333,35 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
|
|||||||
IdToken: idToken,
|
IdToken: idToken,
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
|||||||
IdToken: idToken,
|
IdToken: idToken,
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
ExpiresIn: time.Hour,
|
ExpiresIn: AccessTokenDuration,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +520,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
|||||||
return CreatedTokens{
|
return CreatedTokens{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: newRefreshToken,
|
RefreshToken: newRefreshToken,
|
||||||
ExpiresIn: time.Hour,
|
ExpiresIn: AccessTokenDuration,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
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)
|
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
|
||||||
require.NoError(t, err)
|
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
|
// Create a mock HTTP client with custom transport to return the JWKS
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: &testutils.MockRoundTripper{
|
Transport: &testutils.MockRoundTripper{
|
||||||
@@ -162,8 +170,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
|
|
||||||
// Init the OidcService
|
// Init the OidcService
|
||||||
s := &OidcService{
|
s := &OidcService{
|
||||||
db: db,
|
db: db,
|
||||||
httpClient: httpClient,
|
jwtService: mockJwtService,
|
||||||
|
appConfigService: mockConfig,
|
||||||
|
httpClient: httpClient,
|
||||||
}
|
}
|
||||||
s.jwkCache, err = s.getJWKCache(t.Context())
|
s.jwkCache, err = s.getJWKCache(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -384,4 +394,119 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
assert.Equal(t, federatedClient.ID, client.ID)
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user