diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 0f18d033..b593795f 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -255,3 +255,33 @@ type APIKeyExpirationDateError struct{} func (e *APIKeyExpirationDateError) Error() string { return "API Key expiration time must be in the future" } + +type OidcInvalidRefreshTokenError struct{} + +func (e *OidcInvalidRefreshTokenError) Error() string { + return "refresh token is invalid or expired" +} + +func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int { + return http.StatusBadRequest +} + +type OidcMissingRefreshTokenError struct{} + +func (e *OidcMissingRefreshTokenError) Error() string { + return "refresh token is required" +} + +func (e *OidcMissingRefreshTokenError) HttpStatusCode() int { + return http.StatusBadRequest +} + +type OidcMissingAuthorizationCodeError struct{} + +func (e *OidcMissingAuthorizationCodeError) Error() string { + return "authorization code is required" +} + +func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int { + return http.StatusBadRequest +} diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index be1bba12..607aade4 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -111,28 +111,39 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex // createTokensHandler godoc // @Summary Create OIDC tokens -// @Description Exchange authorization code for ID and access tokens +// @Description Exchange authorization code or refresh token for access tokens // @Tags OIDC -// @Accept application/x-www-form-urlencoded // @Produce json // @Param client_id formData string false "Client ID (if not using Basic Auth)" // @Param client_secret formData string false "Client secret (if not using Basic Auth)" -// @Param code formData string true "Authorization code" -// @Param grant_type formData string true "Grant type (must be 'authorization_code')" -// @Param code_verifier formData string false "PKCE code verifier" -// @Success 200 {object} object "{ \"id_token\": \"string\", \"access_token\": \"string\", \"token_type\": \"Bearer\" }" +// @Param code formData string false "Authorization code (required for 'authorization_code' grant)" +// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')" +// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)" +// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)" +// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token" // @Router /api/oidc/token [post] func (oc *OidcController) createTokensHandler(c *gin.Context) { // Disable cors for this endpoint c.Writer.Header().Set("Access-Control-Allow-Origin", "*") var input dto.OidcCreateTokensDto - if err := c.ShouldBind(&input); err != nil { c.Error(err) return } + // Validate that code is provided for authorization_code grant type + if input.GrantType == "authorization_code" && input.Code == "" { + c.Error(&common.OidcMissingAuthorizationCodeError{}) + return + } + + // Validate that refresh_token is provided for refresh_token grant type + if input.GrantType == "refresh_token" && input.RefreshToken == "" { + c.Error(&common.OidcMissingRefreshTokenError{}) + return + } + clientID := input.ClientID clientSecret := input.ClientSecret @@ -141,13 +152,37 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) { clientID, clientSecret, _ = c.Request.BasicAuth() } - idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier) + idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens( + input.Code, + input.GrantType, + clientID, + clientSecret, + input.CodeVerifier, + input.RefreshToken, + ) + if err != nil { c.Error(err) return } - c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"}) + response := dto.OidcTokenResponseDto{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + } + + // Include ID token only for authorization_code grant + if idToken != "" { + response.IdToken = idToken + } + + // Include refresh token if generated + if refreshToken != "" { + response.RefreshToken = refreshToken + } + + c.JSON(http.StatusOK, response) } // userInfoHandler godoc diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 1ac0f861..3019e3eb 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -54,6 +54,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { "userinfo_endpoint": appUrl + "/api/oidc/userinfo", "end_session_endpoint": appUrl + "/api/oidc/end-session", "jwks_uri": appUrl + "/.well-known/jwks.json", + "grant_types_supported": []string{"authorization_code", "refresh_token"}, "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 ca21aa92..e9f46b81 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -48,10 +48,11 @@ type AuthorizationRequiredDto struct { type OidcCreateTokensDto struct { GrantType string `form:"grant_type" binding:"required"` - Code string `form:"code" binding:"required"` + Code string `form:"code"` ClientID string `form:"client_id"` ClientSecret string `form:"client_secret"` CodeVerifier string `form:"code_verifier"` + RefreshToken string `form:"refresh_token"` } type OidcUpdateAllowedUserGroupsDto struct { @@ -64,3 +65,11 @@ type OidcLogoutDto struct { PostLogoutRedirectUri string `form:"post_logout_redirect_uri"` State string `form:"state"` } + +type OidcTokenResponseDto struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IdToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in"` +} diff --git a/backend/internal/job/db_cleanup.go b/backend/internal/job/db_cleanup.go index 5684f385..8da2c964 100644 --- a/backend/internal/job/db_cleanup.go +++ b/backend/internal/job/db_cleanup.go @@ -22,6 +22,7 @@ func RegisterDbCleanupJobs(db *gorm.DB) { registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions) registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens) registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes) + registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens) scheduler.Start() } @@ -44,6 +45,11 @@ func (j *Jobs) clearOidcAuthorizationCodes() error { return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error } +// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired +func (j *Jobs) clearOidcRefreshTokens() error { + return j.db.Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error +} + // ClearAuditLogs deletes audit logs older than 90 days func (j *Jobs) clearAuditLogs() error { return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 714eb7a1..d2d950b3 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -51,6 +51,20 @@ type OidcClient struct { CreatedBy User } +type OidcRefreshToken struct { + Base + + Token string + ExpiresAt datatype.DateTime + Scope string + + UserID string + User User + + ClientID string + Client OidcClient +} + func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) { // Compute HasLogo field c.HasLogo = c.ImageType != nil && *c.ImageType != "" diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 96967cc7..7579d3ae 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -145,60 +145,121 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode return isAllowedToAuthorize } -func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) { - if grantType != "authorization_code" { - return "", "", &common.OidcGrantTypeNotSupportedError{} - } - - var client model.OidcClient - if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { - return "", "", err - } - - // Verify the client secret if the client is not public - if !client.IsPublic { - if clientID == "" || clientSecret == "" { - return "", "", &common.OidcMissingClientCredentialsError{} +func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier, refreshToken string) (string, string, string, int, error) { + if grantType == "authorization_code" { + var client model.OidcClient + if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { + return "", "", "", 0, err } - err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) + // Verify the client secret if the client is not public + if !client.IsPublic { + if clientID == "" || clientSecret == "" { + return "", "", "", 0, &common.OidcMissingClientCredentialsError{} + } + + err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) + if err != nil { + return "", "", "", 0, &common.OidcClientSecretInvalidError{} + } + } + + var authorizationCodeMetaData model.OidcAuthorizationCode + err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error if err != nil { - return "", "", &common.OidcClientSecretInvalidError{} + return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} } - } - var authorizationCodeMetaData model.OidcAuthorizationCode - err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error - if err != nil { - return "", "", &common.OidcInvalidAuthorizationCodeError{} - } - - // If the client is public or PKCE is enabled, the code verifier must match the code challenge - if client.IsPublic || client.PkceEnabled { - if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { - return "", "", &common.OidcInvalidCodeVerifierError{} + // If the client is public or PKCE is enabled, the code verifier must match the code challenge + if client.IsPublic || client.PkceEnabled { + if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { + return "", "", "", 0, &common.OidcInvalidCodeVerifierError{} + } } + + if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { + return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} + } + + userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID) + if err != nil { + return "", "", "", 0, err + } + + idToken, err := s.jwtService.GenerateIDToken(userClaims, clientID, authorizationCodeMetaData.Nonce) + if err != nil { + return "", "", "", 0, err + } + + // Generate a refresh token + refreshToken, err := s.createRefreshToken(clientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope) + if err != nil { + return "", "", "", 0, err + } + + accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID) + + s.db.Delete(&authorizationCodeMetaData) + + return idToken, accessToken, refreshToken, 3600, nil } - if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { - return "", "", &common.OidcInvalidAuthorizationCodeError{} + if grantType == "refresh_token" { + if refreshToken == "" { + return "", "", "", 0, &common.OidcMissingRefreshTokenError{} + } + + // Get the client to check if it's public + var client model.OidcClient + if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { + return "", "", "", 0, err + } + + // Verify the client secret if the client is not public + if !client.IsPublic { + if clientID == "" || clientSecret == "" { + return "", "", "", 0, &common.OidcMissingClientCredentialsError{} + } + + err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) + if err != nil { + return "", "", "", 0, &common.OidcClientSecretInvalidError{} + } + } + + // Verify refresh token + var storedRefreshToken model.OidcRefreshToken + if err := s.db.Preload("User").Where("token = ? AND expires_at > ?", refreshToken, datatype.DateTime(time.Now())).First(&storedRefreshToken).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", "", 0, &common.OidcInvalidRefreshTokenError{} + } + return "", "", "", 0, err + } + + // Verify that the refresh token belongs to the provided client + if storedRefreshToken.ClientID != clientID { + return "", "", "", 0, &common.OidcInvalidRefreshTokenError{} + } + + // Generate a new access token + accessToken, err := s.jwtService.GenerateOauthAccessToken(storedRefreshToken.User, clientID) + if err != nil { + return "", "", "", 0, err + } + + // Generate a new refresh token and invalidate the old one + newRefreshToken, err := s.createRefreshToken(clientID, storedRefreshToken.UserID, storedRefreshToken.Scope) + if err != nil { + return "", "", "", 0, err + } + + // Delete the used refresh token + s.db.Delete(&storedRefreshToken) + + return "", accessToken, newRefreshToken, 3600, nil } - userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID) - if err != nil { - return "", "", err - } - - idToken, err := s.jwtService.GenerateIDToken(userClaims, clientID, authorizationCodeMetaData.Nonce) - if err != nil { - return "", "", err - } - - accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID) - - s.db.Delete(&authorizationCodeMetaData) - - return idToken, accessToken, nil + return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{} } func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) { @@ -567,3 +628,24 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca return "", &common.OidcInvalidCallbackURLError{} } + +func (s *OidcService) createRefreshToken(clientID string, userID string, scope string) (string, error) { + randomString, err := utils.GenerateRandomAlphanumericString(40) + if err != nil { + return "", err + } + + refreshToken := model.OidcRefreshToken{ + ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), // 30 days + Token: randomString, + ClientID: clientID, + UserID: userID, + Scope: scope, + } + + if err := s.db.Create(&refreshToken).Error; err != nil { + return "", err + } + + return randomString, nil +} diff --git a/backend/internal/service/test_service.go b/backend/internal/service/test_service.go index 40769cdd..9b86bed4 100644 --- a/backend/internal/service/test_service.go +++ b/backend/internal/service/test_service.go @@ -152,6 +152,17 @@ func (s *TestService) SeedDatabase() error { return err } + refreshToken := model.OidcRefreshToken{ + Token: "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo", + ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), + Scope: "openid profile email", + UserID: users[0].ID, + ClientID: oidcClients[0].ID, + } + if err := tx.Create(&refreshToken).Error; err != nil { + return err + } + accessToken := model.OneTimeAccessToken{ Token: "one-time-token", ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), diff --git a/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.down.sql b/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.down.sql new file mode 100644 index 00000000..a81b71af --- /dev/null +++ b/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_oidc_refresh_tokens_token; +DROP TABLE IF EXISTS oidc_refresh_tokens; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.up.sql b/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.up.sql new file mode 100644 index 00000000..fb91b559 --- /dev/null +++ b/backend/resources/migrations/postgres/20250323184520_oidc_refresh_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE oidc_refresh_tokens ( + id UUID NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + scope TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, + client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE +); + +CREATE INDEX idx_oidc_refresh_tokens_token ON oidc_refresh_tokens(token); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.down.sql b/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.down.sql new file mode 100644 index 00000000..a81b71af --- /dev/null +++ b/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_oidc_refresh_tokens_token; +DROP TABLE IF EXISTS oidc_refresh_tokens; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.up.sql b/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.up.sql new file mode 100644 index 00000000..5c3c66c2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250323184520_oidc_refresh_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE oidc_refresh_tokens ( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + scope TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE +); + +CREATE INDEX idx_oidc_refresh_tokens_token ON oidc_refresh_tokens(token); \ No newline at end of file diff --git a/frontend/tests/data.ts b/frontend/tests/data.ts index 5beb1ccb..2d5741fd 100644 --- a/frontend/tests/data.ts +++ b/frontend/tests/data.ts @@ -69,3 +69,16 @@ export const apiKeys = [ name: 'Test API Key' } ]; + +export const refreshTokens = [ + { + token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo', + clientId: oidcClients.nextcloud.id, + expired: false + }, + { + token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4', + clientId: oidcClients.nextcloud.id, + expired: true + } +]; diff --git a/frontend/tests/oidc.spec.ts b/frontend/tests/oidc.spec.ts index 30a7b20e..31c89a65 100644 --- a/frontend/tests/oidc.spec.ts +++ b/frontend/tests/oidc.spec.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test'; -import { oidcClients } from './data'; +import { oidcClients, refreshTokens } from './data'; import { cleanupBackend } from './utils/cleanup.util'; import passkeyUtil from './utils/passkey.util'; @@ -134,3 +134,60 @@ test('End session with id token hint redirects to callback URL', async ({ page } expect(redirectedCorrectly).toBeTruthy(); }); + +test('Successfully refresh tokens with valid refresh token', async ({ request }) => { + const { token, clientId } = refreshTokens.filter((token) => !token.expired)[0]; + const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; + + const refreshResponse = await request.post('/api/oidc/token', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + form: { + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token, + client_secret: clientSecret + } + }); + + // Verify we got new tokens + const tokenData = await refreshResponse.json(); + expect(tokenData.access_token).toBeDefined(); + expect(tokenData.refresh_token).toBeDefined(); + expect(tokenData.token_type).toBe('Bearer'); + expect(tokenData.expires_in).toBe(3600); + + // The new refresh token should be different from the old one + expect(tokenData.refresh_token).not.toBe(token); +}); + +test('Using refresh token invalidates it for future use', async ({ request }) => { + const { token, clientId } = refreshTokens.filter((token) => !token.expired)[0]; + const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; + + await request.post('/api/oidc/token', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + form: { + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token, + client_secret: clientSecret + } + }); + + const refreshResponse = await request.post('/api/oidc/token', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + form: { + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: token, + client_secret: clientSecret + } + }); + expect(refreshResponse.status()).toBe(400); +});