diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 3ab35461..e177c7ee 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -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"}, diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 6d95ee2d..a02e765c 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -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 { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 010952ce..f8b0c638 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -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 } diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index fce54eef..cb6d674b 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -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") + }) + }) + }) }