diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 2c580ee8..6f457321 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -57,6 +57,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler) group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) + + group.DELETE("/oidc/users/me/clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler) + } type OidcController struct { @@ -704,6 +707,27 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { }) } +// revokeOwnClientAuthorizationHandler godoc +// @Summary Revoke authorization for an OIDC client +// @Description Revoke the authorization for a specific OIDC client for the current user +// @Tags OIDC +// @Param clientId path string true "Client ID to revoke authorization for" +// @Success 204 "No Content" +// @Router /api/oidc/users/me/clients/{clientId} [delete] +func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) { + clientID := c.Param("clientId") + + userID := c.GetString("userID") + + err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) { userCode := c.Query("code") if userCode == "" { diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go index 89742c07..15ce27ea 100644 --- a/backend/internal/dto/api_key_dto.go +++ b/backend/internal/dto/api_key_dto.go @@ -6,14 +6,14 @@ import ( type ApiKeyCreateDto struct { Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"` - Description string `json:"description" unorm:"nfc"` + Description *string `json:"description" unorm:"nfc"` ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` } type ApiKeyDto struct { ID string `json:"id"` Name string `json:"name"` - Description string `json:"description"` + Description *string `json:"description"` ExpiresAt datatype.DateTime `json:"expiresAt"` LastUsedAt *datatype.DateTime `json:"lastUsedAt"` CreatedAt datatype.DateTime `json:"createdAt"` diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index f9e91eb5..ab9b937a 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -1,9 +1,12 @@ package dto +import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + type OidcClientMetaDataDto struct { - ID string `json:"id"` - Name string `json:"name"` - HasLogo bool `json:"hasLogo"` + ID string `json:"id"` + Name string `json:"name"` + HasLogo bool `json:"hasLogo"` + LaunchURL *string `json:"launchURL"` } type OidcClientDto struct { @@ -32,6 +35,7 @@ type OidcClientCreateDto struct { IsPublic bool `json:"isPublic"` PkceEnabled bool `json:"pkceEnabled"` Credentials OidcClientCredentialsDto `json:"credentials"` + LaunchURL *string `json:"launchURL" binding:"omitempty,url"` } type OidcClientCredentialsDto struct { @@ -145,8 +149,9 @@ type DeviceCodeInfoDto struct { } type AuthorizedOidcClientDto struct { - Scope string `json:"scope"` - Client OidcClientMetaDataDto `json:"client"` + Scope string `json:"scope"` + Client OidcClientMetaDataDto `json:"client"` + LastUsedAt datatype.DateTime `json:"lastUsedAt"` } type OidcClientPreviewDto struct { diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 490015bf..5becca05 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -11,7 +11,9 @@ import ( ) type UserAuthorizedOidcClient struct { - Scope string + Scope string + LastUsedAt datatype.DateTime `sortable:"true"` + UserID string `gorm:"primary_key;"` User User @@ -47,6 +49,7 @@ type OidcClient struct { IsPublic bool PkceEnabled bool Credentials OidcClientCredentials + LaunchURL *string AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` CreatedByID string diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 4ae4c810..547ca641 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d apiKey := model.ApiKey{ Name: input.Name, Key: utils.CreateSha256Hash(token), // Hash the token for storage - Description: &input.Description, - ExpiresAt: datatype.DateTime(input.ExpiresAt), + Description: input.Description, + ExpiresAt: input.ExpiresAt, UserID: userID, } diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 9043a954..27b5e20d 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -154,6 +154,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", }, Name: "Nextcloud", + LaunchURL: utils.Ptr("https://nextcloud.local"), Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"}, LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"}, @@ -172,6 +173,16 @@ func (s *TestService) SeedDatabase(baseURL string) error { userGroups[1], }, }, + { + Base: model.Base{ + ID: "7c21a609-96b5-4011-9900-272b8d31a9d1", + }, + Name: "Tailscale", + Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo + CallbackURLs: model.UrlList{"http://tailscale/auth/callback"}, + LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"}, + CreatedByID: users[0].ID, + }, { Base: model.Base{ ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b", @@ -245,14 +256,22 @@ func (s *TestService) SeedDatabase(baseURL string) error { userAuthorizedClients := []model.UserAuthorizedOidcClient{ { - Scope: "openid profile email", - UserID: users[0].ID, - ClientID: oidcClients[0].ID, + Scope: "openid profile email", + UserID: users[0].ID, + ClientID: oidcClients[0].ID, + LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)), }, { - Scope: "openid profile email", - UserID: users[1].ID, - ClientID: oidcClients[2].ID, + Scope: "openid profile email", + UserID: users[0].ID, + ClientID: oidcClients[2].ID, + LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)), + }, + { + Scope: "openid profile email", + UserID: users[1].ID, + ClientID: oidcClients[3].ID, + LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)), }, } for _, userAuthorizedClient := range userAuthorizedClients { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 209837c0..fada6a25 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -149,20 +149,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie return "", "", &common.OidcAccessDeniedError{} } - // Check if the user has already authorized the client with the given scope - hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx) + hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx) if err != nil { return "", "", err } - // If the user has not authorized the client, create a new authorization in the database - if !hasAuthorizedClient { - err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx) - if err != nil { - return "", "", err - } - } - // Create the authorization code code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx) if err != nil { @@ -170,7 +161,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie } // Log the authorization event - if hasAuthorizedClient { + if hasAlreadyAuthorizedClient { s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx) } else { s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx) @@ -724,6 +715,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.IsPublic = input.IsPublic // PKCE is required for public clients client.PkceEnabled = input.IsPublic || input.PkceEnabled + client.LaunchURL = input.LaunchURL // Credentials if len(input.Credentials.FederatedIdentities) > 0 { @@ -1231,22 +1223,16 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use return fmt.Errorf("error saving device auth: %w", err) } - // Create user authorization if needed - hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx) + hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx) if err != nil { return err } auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name} - if !hasAuthorizedClient { - err = s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx) - if err != nil { - return err - } - - s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx) - } else { + if hasAlreadyAuthorizedClient { s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx) + } else { + s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx) } return tx.Commit().Error @@ -1322,6 +1308,34 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, return authorizedClients, response, err } +func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var authorizedClient model.UserAuthorizedOidcClient + err := tx. + WithContext(ctx). + Where("user_id = ? AND client_id = ?", userID, clientID). + First(&authorizedClient).Error + if err != nil { + return err + } + + err = tx.WithContext(ctx).Delete(&authorizedClient).Error + if err != nil { + return err + } + + err = tx.Commit().Error + if err != nil { + return err + } + + return nil +} + func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) { refreshToken, err := utils.GenerateRandomAlphanumericString(40) if err != nil { @@ -1357,14 +1371,37 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u return signed, nil } -func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error { - userAuthorizedClient := model.UserAuthorizedOidcClient{ - UserID: userID, - ClientID: clientID, - Scope: scope, +func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) { + + // Check if the user has already authorized the client with the given scope + hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx) + if err != nil { + return false, err } - err := tx.WithContext(ctx). + if hasAlreadyAuthorizedClient { + err = tx. + WithContext(ctx). + Model(&model.UserAuthorizedOidcClient{}). + Where("user_id = ? AND client_id = ?", userID, clientID). + Update("last_used_at", datatype.DateTime(time.Now())). + Error + + if err != nil { + return hasAlreadyAuthorizedClient, err + } + + return hasAlreadyAuthorizedClient, nil + } + + userAuthorizedClient := model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: scope, + LastUsedAt: datatype.DateTime(time.Now()), + } + + err = tx.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, DoUpdates: clause.AssignmentColumns([]string{"scope"}), @@ -1372,7 +1409,7 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID Create(&userAuthorizedClient). Error - return err + return hasAlreadyAuthorizedClient, err } type ClientAuthCredentials struct { @@ -1704,3 +1741,19 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut return claims, nil } + +func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) { + var user model.User + err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error + if err != nil { + return false, err + } + + var client model.OidcClient + err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error + if err != nil { + return false, err + } + + return s.IsUserGroupAllowedToAuthorize(user, client), nil +} diff --git a/backend/resources/migrations/postgres/20250810144214_apps_dashboard.down.sql b/backend/resources/migrations/postgres/20250810144214_apps_dashboard.down.sql new file mode 100644 index 00000000..dba17981 --- /dev/null +++ b/backend/resources/migrations/postgres/20250810144214_apps_dashboard.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE oidc_clients DROP COLUMN launch_url; + +ALTER TABLE user_authorized_oidc_clients DROP COLUMN last_used_at; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250810144214_apps_dashboard.up.sql b/backend/resources/migrations/postgres/20250810144214_apps_dashboard.up.sql new file mode 100644 index 00000000..3c1ed876 --- /dev/null +++ b/backend/resources/migrations/postgres/20250810144214_apps_dashboard.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT; + +ALTER TABLE user_authorized_oidc_clients ADD COLUMN last_used_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.down.sql b/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.down.sql new file mode 100644 index 00000000..1267c6ef --- /dev/null +++ b/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE oidc_clients DROP COLUMN launch_url; + +ALTER TABLE user_authorized_oidc_clients DROP COLUMN created_at; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.up.sql b/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.up.sql new file mode 100644 index 00000000..d5e3085c --- /dev/null +++ b/backend/resources/migrations/sqlite/20250810144214_apps_dashboard.up.sql @@ -0,0 +1,16 @@ +ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT; + +CREATE TABLE user_authorized_oidc_clients_new +( + scope TEXT, + user_id TEXT, + client_id TEXT REFERENCES oidc_clients, + last_used_at DATETIME NOT NULL, + PRIMARY KEY (user_id, client_id) +); + +INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at) +SELECT scope, user_id, client_id, unixepoch() FROM user_authorized_oidc_clients; + +DROP TABLE user_authorized_oidc_clients; +ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e6aef1cd..5b1c5c16 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -419,5 +419,15 @@ "signup_open_description": "Anyone can create a new account without restrictions.", "of": "of", "skip_passkey_setup": "Skip Passkey Setup", - "skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires." + "skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires.", + "my_apps": "My Apps", + "no_apps_available": "No apps available", + "contact_your_administrator_for_app_access": "Contact your administrator to get access to applications.", + "launch": "Launch", + "client_launch_url": "Client Launch URL", + "client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.", + "client_name_description": "The name of the client that shows in the Pocket ID UI.", + "revoke_access": "Revoke Access", + "revoke_access_description": "Revoke access to {clientName}. {clientName} will no longer be able to access your account information.", + "revoke_access_successful": "The access to {clientName} has been successfully revoked." } diff --git a/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte b/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte index f52d69e0..e26b68eb 100644 --- a/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte +++ b/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte @@ -1,6 +1,7 @@ @@ -9,7 +10,7 @@ {$confirmDialogStore.title} - {$confirmDialogStore.message} + diff --git a/frontend/src/lib/components/formatted-message.svelte b/frontend/src/lib/components/formatted-message.svelte index ddec9459..a8f1a2d7 100644 --- a/frontend/src/lib/components/formatted-message.svelte +++ b/frontend/src/lib/components/formatted-message.svelte @@ -8,53 +8,66 @@ } = $props(); interface MessagePart { - type: 'text' | 'link'; + type: 'text' | 'link' | 'bold'; content: string; href?: string; } + // Extracts attribute value from a tag's attribute string + function getAttr(attrs: string, name: string): string | undefined { + const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i'); + const m = re.exec(attrs ?? ''); + return m?.[2]; + } + + const handlers: Record MessagePart | null> = { + link: (attrs, inner) => { + const href = getAttr(attrs, 'href'); + if (!href) return { type: 'text', content: inner }; + return { type: 'link', content: inner, href }; + }, + b: (_attrs, inner) => ({ type: 'bold', content: inner }) + }; + + function buildTokenRegex(): RegExp { + const keys = Object.keys(handlers).join('|'); + // Matches: inner for allowed tags only + return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g'); + } + function parseMessage(content: string): MessagePart[] | string { - // Regex to match only text format - const linkRegex = /(.*?)<\/link>/g; - - if (!linkRegex.test(content)) { - return content; - } - - // Reset regex lastIndex for reuse - linkRegex.lastIndex = 0; + const tokenRegex = buildTokenRegex(); + if (!tokenRegex.test(content)) return content; + // Reset lastIndex for reuse + tokenRegex.lastIndex = 0; const parts: MessagePart[] = []; let lastIndex = 0; - let match; + let match: RegExpExecArray | null; - while ((match = linkRegex.exec(content)) !== null) { - // Add text before the link + while ((match = tokenRegex.exec(content)) !== null) { + // Add text before the matched token if (match.index > lastIndex) { const textContent = content.slice(lastIndex, match.index); - if (textContent) { - parts.push({ type: 'text', content: textContent }); - } + if (textContent) parts.push({ type: 'text', content: textContent }); } - const href = match[2]; - const linkText = match[3]; - - parts.push({ - type: 'link', - content: linkText, - href: href - }); + const tag = match[1]; + const attrs = match[2] ?? ''; + const inner = match[3] ?? ''; + const handler = handlers[tag]; + const part: MessagePart | null = handler + ? handler(attrs, inner) + : { type: 'text', content: inner }; + if (part) parts.push(part); lastIndex = match.index + match[0].length; } - // Add remaining text after the last link + // Add remaining text after the last token if (lastIndex < content.length) { const remainingText = content.slice(lastIndex); - if (remainingText) { - parts.push({ type: 'text', content: remainingText }); - } + if (remainingText) parts.push({ type: 'text', content: remainingText }); } return parts; @@ -69,6 +82,10 @@ {#each parsedContent as part} {#if part.type === 'text'} {part.content} + {:else if part.type === 'bold'} + + {part.content} + {:else if part.type === 'link'} + goto('/settings/apps')} + > {m.my_apps()} goto('/settings/account')} > {m.my_account()} diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index aea3a069..2794ea40 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -1,4 +1,5 @@ import type { + AuthorizedOidcClient, AuthorizeResponse, OidcClient, OidcClientCreate, @@ -113,6 +114,24 @@ class OidcService extends APIService { }); return response.data; } + + async listAuthorizedClients(options?: SearchPaginationSortRequest) { + const res = await this.api.get('/oidc/users/me/clients', { + params: options + }); + return res.data as Paginated; + } + + async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) { + const res = await this.api.get(`/oidc/users/${userId}/clients`, { + params: options + }); + return res.data as Paginated; + } + + async revokeOwnAuthorizedClient(clientId: string) { + await this.api.delete(`/oidc/users/me/clients/${clientId}`); + } } export default OidcService; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 3777af61..6430bf4e 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -4,6 +4,7 @@ export type OidcClientMetaData = { id: string; name: string; hasLogo: boolean; + launchURL?: string; }; export type OidcClientFederatedIdentity = { @@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & { isPublic: boolean; pkceEnabled: boolean; credentials?: OidcClientCredentials; + launchURL?: string; }; export type OidcClientWithAllowedUserGroups = OidcClient & { @@ -50,3 +52,8 @@ export type AuthorizeResponse = { callbackURL: string; issuer: string; }; + +export type AuthorizedOidcClient = { + scope: string; + client: OidcClientMetaData; +}; diff --git a/frontend/src/lib/utils/form-util.ts b/frontend/src/lib/utils/form-util.ts index afeda980..3251d8cb 100644 --- a/frontend/src/lib/utils/form-util.ts +++ b/frontend/src/lib/utils/form-util.ts @@ -54,6 +54,13 @@ export function createForm>(schema: T, initialValu inputs[input as keyof z.infer].error = null; } } + // Update the input values with the parsed data + for (const key in result.data) { + if (Object.prototype.hasOwnProperty.call(inputs, key)) { + inputs[key as keyof z.infer].value = result.data[key]; + } + } + return inputs; }); return success ? data() : null; diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts new file mode 100644 index 00000000..0a304415 --- /dev/null +++ b/frontend/src/lib/utils/zod-util.ts @@ -0,0 +1,11 @@ +import z from 'zod/v4'; + +export const optionalString = z + .string() + .transform((v) => (v === '' ? undefined : v)) + .optional(); + +export const optionalUrl = z + .url() + .optional() + .or(z.literal('').transform(() => undefined)); diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index 6c60d62b..fcbc08ef 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte index 9a44aac2..b65af4ec 100644 --- a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte +++ b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte @@ -5,6 +5,7 @@ import type { ApiKeyCreate } from '$lib/types/api-key.type'; import { preventDefault } from '$lib/utils/event-util'; import { createForm } from '$lib/utils/form-util'; + import { optionalString } from '$lib/utils/zod-util'; import { z } from 'zod/v4'; let { @@ -27,7 +28,7 @@ const formSchema = z.object({ name: z.string().min(3).max(50), - description: z.string().default(''), + description: optionalString, expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future()) }); diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index 71a2ce73..d345af5c 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -16,6 +16,7 @@ import { z } from 'zod/v4'; import FederatedIdentitiesInput from './federated-identities-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; + import { optionalUrl } from '$lib/utils/zod-util'; let { callback, @@ -38,6 +39,7 @@ logoutCallbackURLs: existingClient?.logoutCallbackURLs || [], isPublic: existingClient?.isPublic || false, pkceEnabled: existingClient?.pkceEnabled || false, + launchURL: existingClient?.launchURL || '', credentials: { federatedIdentities: existingClient?.credentials?.federatedIdentities || [] } @@ -49,6 +51,7 @@ logoutCallbackURLs: z.array(z.string().nonempty()), isPublic: z.boolean(), pkceEnabled: z.boolean(), + launchURL: optionalUrl, credentials: z.object({ federatedIdentities: z.array( z.object({ @@ -106,8 +109,18 @@ - - + + + import { openConfirmDialog } from '$lib/components/confirm-dialog'; + import * as Pagination from '$lib/components/ui/pagination'; + import { m } from '$lib/paraglide/messages'; + import OIDCService from '$lib/services/oidc-service'; + import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type'; + import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; + import { axiosErrorToast } from '$lib/utils/error-util'; + import { LayoutDashboard } from '@lucide/svelte'; + import { toast } from 'svelte-sonner'; + import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte'; + + let { data } = $props(); + let authorizedClients: Paginated = $state(data.authorizedClients); + let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions); + + const oidcService = new OIDCService(); + + async function onRefresh(options: SearchPaginationSortRequest) { + authorizedClients = await oidcService.listAuthorizedClients(options); + } + + async function onPageChange(page: number) { + requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page }; + onRefresh(requestOptions); + } + + async function revokeAuthorizedClient(client: OidcClientMetaData) { + openConfirmDialog({ + title: m.revoke_access(), + message: m.revoke_access_description({ + clientName: client.name + }), + confirm: { + label: m.revoke(), + destructive: true, + action: async () => { + try { + await oidcService.revokeOwnAuthorizedClient(client.id); + onRefresh(requestOptions); + toast.success( + m.revoke_access_successful({ + clientName: client.name + }) + ); + } catch (e) { + axiosErrorToast(e); + } + } + } + }); + } + + + + {m.my_apps()} + + + + + + + {m.my_apps()} + + + + {#if authorizedClients.data.length === 0} + + + + {m.no_apps_available()} + + + {m.contact_your_administrator_for_app_access()} + + + {:else} + + + {#each authorizedClients.data as authorizedClient} + + {/each} + + + {#if authorizedClients.pagination.totalPages > 1} + + + {#snippet children({ pages })} + + + + + {#each pages as page (page.key)} + {#if page.type !== 'ellipsis' && page.value != 0} + + + {page.value} + + + {/if} + {/each} + + + + + {/snippet} + + + {/if} + + {/if} + diff --git a/frontend/src/routes/settings/apps/+page.ts b/frontend/src/routes/settings/apps/+page.ts new file mode 100644 index 00000000..675892d4 --- /dev/null +++ b/frontend/src/routes/settings/apps/+page.ts @@ -0,0 +1,22 @@ +import OIDCService from '$lib/services/oidc-service'; +import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + const oidcService = new OIDCService(); + + const appRequestOptions: SearchPaginationSortRequest = { + pagination: { + page: 1, + limit: 20 + }, + sort: { + column: 'lastUsedAt', + direction: 'desc' + } + }; + + const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions); + + return { authorizedClients, appRequestOptions }; +}; diff --git a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte new file mode 100644 index 00000000..79e631fa --- /dev/null +++ b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte @@ -0,0 +1,102 @@ + + + + + + + + + + + + + {authorizedClient.client.name} + + + {#if authorizedClient.client.launchURL} + + {new URL(authorizedClient.client.launchURL).hostname} + + {/if} + + + + + + {m.toggle_menu()} + + + goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)} + > {m.edit()} + {#if $userStore?.isAdmin} + onRevoke(authorizedClient.client)} + >{m.revoke()} + {/if} + + + + + + + + + {m.launch()} + + + + + + + diff --git a/tests/data.ts b/tests/data.ts index 3970602e..76775140 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -27,7 +27,8 @@ export const oidcClients = { name: 'Nextcloud', callbackUrl: 'http://nextcloud/auth/callback', logoutCallbackUrl: 'http://nextcloud/auth/logout/callback', - secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY' + secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY', + launchURL: 'https://nextcloud.local' }, immich: { id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018', @@ -35,6 +36,12 @@ export const oidcClients = { callbackUrl: 'http://immich/auth/callback', secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x' }, + tailscale: { + id: '7c21a609-96b5-4011-9900-272b8d31a9d1', + name: 'Tailscale', + callbackUrl: 'http://tailscale/auth/callback', + secret: 'n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo', + }, federated: { id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b', name: 'Federated', @@ -49,7 +56,8 @@ export const oidcClients = { pingvinShare: { name: 'Pingvin Share', callbackUrl: 'http://pingvin.share/auth/callback', - secondCallbackUrl: 'http://pingvin.share/auth/callback2' + secondCallbackUrl: 'http://pingvin.share/auth/callback2', + launchURL: 'https://pingvin-share.local' } }; diff --git a/tests/specs/apps-dashboard.spec.ts b/tests/specs/apps-dashboard.spec.ts new file mode 100644 index 00000000..5e6e687f --- /dev/null +++ b/tests/specs/apps-dashboard.spec.ts @@ -0,0 +1,59 @@ +import test, { expect } from '@playwright/test'; +import { oidcClients } from '../data'; +import { cleanupBackend } from '../utils/cleanup.util'; + +test.beforeEach(() => cleanupBackend()); + +test('Dashboard shows all authorized clients in the correct order', async ({ page }) => { + const client1 = oidcClients.tailscale; + const client2 = oidcClients.nextcloud; + + await page.goto('/settings/apps'); + + await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(2); + + // Should be first + const card1 = page.getByTestId('authorized-oidc-client-card').first(); + + await expect(card1.getByRole('heading')).toHaveText(client1.name); + + const card2 = page.getByTestId('authorized-oidc-client-card').nth(1); + await expect(card2.getByRole('heading', { name: client2.name })).toBeVisible(); + await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible(); +}); + +test('Revoke authorized client', async ({ page }) => { + const client = oidcClients.tailscale; + + await page.goto('/settings/apps'); + + page + .getByTestId('authorized-oidc-client-card') + .first() + .getByRole('button', { name: 'Toggle menu' }) + .click(); + + await page.getByRole('menuitem', { name: 'Revoke' }).click(); + await page.getByRole('button', { name: 'Revoke' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText( + `The access to ${client.name} has been successfully revoked.` + ); + + await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(1); +}); + +test('Launch authorized client', async ({ page }) => { + const client = oidcClients.nextcloud; + + await page.goto('/settings/apps'); + + const card1 = page.getByTestId('authorized-oidc-client-card').first(); + await expect(card1.getByRole('button', { name: 'Launch' })).toBeDisabled(); + + const card2 = page.getByTestId('authorized-oidc-client-card').nth(1); + await expect(card2.getByRole('link', { name: 'Launch' })).toHaveAttribute( + 'href', + client.launchURL + ); +}); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index 0ff6c4e5..57446ad2 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -11,6 +11,8 @@ test('Create OIDC client', async ({ page }) => { await page.getByRole('button', { name: 'Add OIDC Client' }).click(); await page.getByLabel('Name').fill(oidcClient.name); + await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); + await page.getByRole('button', { name: 'Add' }).nth(1).click(); await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl); await page.getByRole('button', { name: 'Add another' }).click(); @@ -42,6 +44,7 @@ test('Edit OIDC client', async ({ page }) => { await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); await page.getByLabel('logo').setInputFiles('assets/nextcloud-logo.png'); + await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.locator('[data-type="success"]')).toHaveText(
+ {m.contact_your_administrator_for_app_access()} +
+ {new URL(authorizedClient.client.launchURL).hostname} +