Skip to content

Commit f086171

Browse files
add copy UAA token button to page header
Ports upstream PR #5169 (author: Jan-Robin Aumann) to modern Stratos patterns on feature/Angular-21. Backend (clean port): - api/structs.go — JSON tags on TokenRecord so it can be serialised - session.go — new AuthTokenEnvelope struct and retrieveToken handler. The handler reads user_id from the signed session cookie, verifies the session, then returns the UAA TokenRecord for that user only. No client-supplied user identifier anywhere — users can only ever retrieve their own token. - main.go — GET /api/v1/auth/token route Frontend (rewritten for Angular 21 patterns): - store/types/auth.types.ts — TokenData + AuthTokenEnvelope interfaces - store/public-api.ts — exported via `export type` - page-header.component.ts — uses inject(HttpClient) + inject(SnackBarService), firstValueFrom(), and navigator.clipboard.writeText(). No ngx-clipboard dependency. - page-header.component.html — new vpn_key button + dropdown in the Tailwind toggle pattern (isTokenMenuOpen), @if control flow, no mat-* Security hardening over the upstream PR: the original kept each token in a hidden <div>{{ token$ | async }}</div> buffer for the clipboard-copy library to read, which left the raw tokens live in the DOM where browser extensions could scrape them. This port skips the DOM buffer entirely — the token is fetched on-demand when the user clicks Copy and written straight to the clipboard API. The /auth/token envelope is also re-fetched every time the menu opens (via shareReplay(1) in a per-open observable) so users can't receive a stale or expired token from a process-lifetime cache. Co-authored-by: Jan-Robin Aumann <jaumann@anynines.com>
1 parent 67358c5 commit f086171

7 files changed

Lines changed: 193 additions & 16 deletions

File tree

src/frontend/packages/core/src/shared/components/page-header/page-header.component.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@
7272
<!-- Theme toggle -->
7373
<app-theme-toggle></app-theme-toggle>
7474

75+
<!-- Copy UAA token button -->
76+
@if (!hideMenu && !logoutOnly && !environment.hideUserMenu) {
77+
<div class="relative">
78+
<button class="page-header-menu-button p-2 text-header-text hover:bg-white/10 rounded-md transition-colors"
79+
(click)="toggleTokenMenu()"
80+
title="Copy UAA tokens">
81+
<span class="material-icons">vpn_key</span>
82+
</button>
83+
@if (isTokenMenuOpen) {
84+
<div
85+
class="absolute right-0 top-full mt-2 bg-content-bg rounded-lg shadow-lg border border-content-border z-50">
86+
<div class="page-header-token p-4 w-64">
87+
<h3 class="page-header-token-title text-lg font-semibold text-content-text mb-3">UAA Tokens</h3>
88+
<div class="space-y-2">
89+
<button class="w-full text-left px-3 py-2 text-sm text-content-text hover:bg-content-secondary rounded-md transition-colors border border-content-border"
90+
(click)="copyToken(authToken$, 'Auth token'); closeTokenMenu()">
91+
Copy Auth Token
92+
</button>
93+
<button class="w-full text-left px-3 py-2 text-sm text-content-text hover:bg-content-secondary rounded-md transition-colors border border-content-border"
94+
(click)="copyToken(refreshToken$, 'Refresh token'); closeTokenMenu()">
95+
Copy Refresh Token
96+
</button>
97+
</div>
98+
<div class="mt-3 pt-3 border-t border-content-border">
99+
<div class="text-xs font-medium text-content-muted mb-1">Token expiry</div>
100+
<div class="text-sm text-content-text break-words">{{ (tokenExpiry$ | async) | date:'medium' }}</div>
101+
</div>
102+
</div>
103+
</div>
104+
}
105+
</div>
106+
}
107+
75108
<!-- History button -->
76109
@if (showHistory) {
77110
<div class="relative">

src/frontend/packages/core/src/shared/components/page-header/page-header.component.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Portal, TemplatePortal } from '@angular/cdk/portal';
22
import { CommonModule } from '@angular/common';
3+
import { HttpClient } from '@angular/common/http';
34
import { ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit, Component, Input, OnDestroy, TemplateRef, ViewChild, inject } from '@angular/core';
45
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
56
import { Store } from '@ngrx/store';
@@ -14,10 +15,12 @@ import {
1415
AppState,
1516
selectIsMobile,
1617
UserProfileInfo,
18+
AuthTokenEnvelope,
1719
} from '@stratosui/store';
1820
import { getTime } from 'date-fns';
19-
import { combineLatest, Observable } from 'rxjs';
21+
import { combineLatest, firstValueFrom, Observable, shareReplay } from 'rxjs';
2022
import { map, startWith } from 'rxjs/operators';
23+
import { SnackBarService } from '../../services/snackbar.service';
2124

2225
import { CurrentUserPermissionsService } from '../../../core/permissions/current-user-permissions.service';
2326
import { StratosCurrentUserPermissions } from '../../../core/permissions/stratos-user-permissions.checker';
@@ -62,6 +65,8 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
6265
private endpointsService = inject(EndpointsService);
6366
private currentUserPermissionsService = inject(CurrentUserPermissionsService);
6467
private cdr = inject(ChangeDetectorRef);
68+
private http = inject(HttpClient);
69+
private snackBarService = inject(SnackBarService);
6570

6671
public canAPIKeys$: Observable<boolean>;
6772
public breadcrumbDefinitions: IHeaderBreadcrumbLink[] = null;
@@ -77,6 +82,7 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
7782
// Menu state for Tailwind dropdowns
7883
public isHistoryMenuOpen = false;
7984
public isUserMenuOpen = false;
85+
public isTokenMenuOpen = false;
8086

8187
@ViewChild('pageHeaderTmpl', { static: true }) pageHeaderTmpl!: TemplateRef<any>;
8288

@@ -158,6 +164,9 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
158164
public user$: Observable<UserProfileInfo>;
159165
public allowGravatar$: Observable<boolean>;
160166
public canLogout$: Observable<boolean>;
167+
public authToken$!: Observable<string>;
168+
public refreshToken$!: Observable<string>;
169+
public tokenExpiry$!: Observable<Date | null>;
161170

162171
public actionsKey: string;
163172

@@ -238,7 +247,38 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
238247
this.canLogout$ = this.currentUserPermissionsService.can(StratosCurrentUserPermissions.CAN_NOT_LOGOUT).pipe(
239248
map(noLogout => !noLogout)
240249
);
250+
}
251+
252+
// Fetch fresh token envelope — called every time the token menu opens so
253+
// the user can't grab a stale/expired token from a cached observable. The
254+
// three derived observables share one HTTP call via shareReplay(1).
255+
private refreshTokenObservables() {
256+
const env$ = this.http
257+
.get<AuthTokenEnvelope>(`/api/${environment.proxyAPIVersion}/auth/token`)
258+
.pipe(shareReplay(1));
259+
260+
this.authToken$ = env$.pipe(map(res => res.data?.auth_token ?? ''));
261+
this.refreshToken$ = env$.pipe(map(res => res.data?.refresh_token ?? ''));
262+
this.tokenExpiry$ = env$.pipe(
263+
map(res => res.data ? new Date(res.data.token_expiry * 1000) : null)
264+
);
265+
}
241266

267+
// Copy a token value to the clipboard without ever rendering it in the DOM.
268+
// The token$ observable is resolved at click time and piped straight into
269+
// the browser clipboard API — no hidden <div>{{ token }}</div> buffer.
270+
async copyToken(token$: Observable<string>, label: string) {
271+
try {
272+
const value = await firstValueFrom(token$);
273+
if (!value) {
274+
this.snackBarService.show(`No ${label} available`, 'Close');
275+
return;
276+
}
277+
await navigator.clipboard.writeText(value);
278+
this.snackBarService.show(`${label} copied to clipboard`, 'Close');
279+
} catch {
280+
this.snackBarService.show(`Failed to copy ${label}`, 'Close');
281+
}
242282
}
243283

244284
ngOnDestroy() {
@@ -267,14 +307,25 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
267307
toggleHistoryMenu() {
268308
this.isHistoryMenuOpen = !this.isHistoryMenuOpen;
269309
if (this.isHistoryMenuOpen) {
270-
this.isUserMenuOpen = false; // Close user menu when opening history
310+
this.isUserMenuOpen = false;
311+
this.isTokenMenuOpen = false;
271312
}
272313
}
273314

274315
toggleUserMenu() {
275316
this.isUserMenuOpen = !this.isUserMenuOpen;
276317
if (this.isUserMenuOpen) {
277-
this.isHistoryMenuOpen = false; // Close history menu when opening user menu
318+
this.isHistoryMenuOpen = false;
319+
this.isTokenMenuOpen = false;
320+
}
321+
}
322+
323+
toggleTokenMenu() {
324+
this.isTokenMenuOpen = !this.isTokenMenuOpen;
325+
if (this.isTokenMenuOpen) {
326+
this.isHistoryMenuOpen = false;
327+
this.isUserMenuOpen = false;
328+
this.refreshTokenObservables();
278329
}
279330
}
280331

@@ -286,4 +337,8 @@ export class PageHeaderComponent implements OnDestroy, AfterViewInit {
286337
this.isHistoryMenuOpen = false;
287338
}
288339

340+
closeTokenMenu() {
341+
this.isTokenMenuOpen = false;
342+
}
343+
289344
}

src/frontend/packages/store/src/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export { LocalStorageService, LocalStorageSyncTypes } from './helpers/local-stor
99
// Used by store testing module
1010
export { getDefaultRequestState } from './reducers/api-request-reducer/types';
1111
export { getDefaultPaginationEntityState } from './reducers/pagination-reducer/pagination-reducer-reset-pagination';
12-
export type { SessionDataEndpoint } from './types/auth.types';
12+
export type { AuthTokenEnvelope, SessionDataEndpoint, TokenData } from './types/auth.types';
1313
export { getDefaultRolesRequestState } from './types/current-user-roles.types';
1414
export type { BaseEntityValues } from './types/entity.types';
1515
export type {

src/frontend/packages/store/src/types/auth.types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ export interface SessionDataEnvelope {
8181
data?: SessionData;
8282
}
8383

84+
export interface TokenData {
85+
token_guid: string;
86+
auth_token: string;
87+
refresh_token: string;
88+
token_expiry: number;
89+
disconnected: boolean;
90+
auth_type: string;
91+
metadata: string;
92+
system_shared: boolean;
93+
linked_guid: string;
94+
certificate: string;
95+
certificate_key: string;
96+
enabled: boolean;
97+
}
98+
99+
export interface AuthTokenEnvelope {
100+
status: string;
101+
error?: string;
102+
data?: TokenData;
103+
}
104+
84105
export interface Diagnostics {
85106
deploymentType?: string;
86107
gitClientVersion?: string;

src/jetstream/api/structs.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,18 +139,18 @@ type BackupTokenRecord struct {
139139

140140
// TokenRecord repsrents and endpoint or uaa token
141141
type TokenRecord struct {
142-
TokenGUID string
143-
AuthToken string
144-
RefreshToken string
145-
TokenExpiry int64
146-
Disconnected bool
147-
AuthType string
148-
Metadata string
149-
SystemShared bool
150-
LinkedGUID string // Indicates the GUID of the token that this token is linked to (if any)
151-
Certificate string
152-
CertificateKey string
153-
Enabled bool
142+
TokenGUID string `json:"token_guid"`
143+
AuthToken string `json:"auth_token"`
144+
RefreshToken string `json:"refresh_token"`
145+
TokenExpiry int64 `json:"token_expiry"`
146+
Disconnected bool `json:"disconnected"`
147+
AuthType string `json:"auth_type"`
148+
Metadata string `json:"metadata"`
149+
SystemShared bool `json:"system_shared"`
150+
LinkedGUID string `json:"linked_guid"` // Indicates the GUID of the token that this token is linked to (if any)
151+
Certificate string `json:"certificate"`
152+
CertificateKey string `json:"certificate_key"`
153+
Enabled bool `json:"enabled"`
154154
}
155155

156156
type CFInfo struct {

src/jetstream/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,9 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, needSetupMiddleware bool) {
928928
// Verify Session
929929
api.GET("/v1/auth/verify", p.verifySession)
930930

931+
// Retrieve UAA token for the current session (for operator tooling, e.g. cf CLI curl commands)
932+
api.GET("/v1/auth/token", p.retrieveToken)
933+
931934
// Always serve the backend API from /pp
932935
pp := e.Group("/pp")
933936

src/jetstream/session.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ type SessionInfoEnvelope struct {
5050
Data *api.Info `json:"data"`
5151
}
5252

53+
// AuthTokenEnvelope -- contains response status, data and/or errors for the UAA token retrieval endpoint
54+
type AuthTokenEnvelope struct {
55+
Status string `json:"status"`
56+
Error string `json:"error"`
57+
Data *api.TokenRecord `json:"data"`
58+
}
59+
5360
func (e *SessionValueNotFound) Error() string {
5461
return fmt.Sprintf("Session value not found %s", e.msg)
5562
}
@@ -318,3 +325,61 @@ func (p *portalProxy) verifySession(c echo.Context) error {
318325
},
319326
)
320327
}
328+
329+
func (p *portalProxy) retrieveToken(c echo.Context) error {
330+
log.Debug("retrieveToken")
331+
332+
p.StratosAuthService.BeforeVerifySession(c)
333+
334+
sessionExpireTime, err := p.GetSessionInt64Value(c, "exp")
335+
if err != nil {
336+
return c.JSON(
337+
http.StatusOK,
338+
AuthTokenEnvelope{
339+
Status: "error",
340+
Error: err.Error(),
341+
},
342+
)
343+
}
344+
345+
sessionUser, err := p.GetSessionStringValue(c, "user_id")
346+
if err != nil {
347+
return c.JSON(
348+
http.StatusOK,
349+
AuthTokenEnvelope{
350+
Status: "error",
351+
Error: err.Error(),
352+
},
353+
)
354+
}
355+
356+
err = p.StratosAuthService.VerifySession(c, sessionUser, sessionExpireTime)
357+
if err != nil {
358+
return c.JSON(
359+
http.StatusOK,
360+
AuthTokenEnvelope{
361+
Status: "error",
362+
Error: err.Error(),
363+
},
364+
)
365+
}
366+
367+
record, err := p.GetUAATokenRecord(sessionUser)
368+
if err != nil {
369+
return c.JSON(
370+
http.StatusOK,
371+
AuthTokenEnvelope{
372+
Status: "error",
373+
Error: err.Error(),
374+
},
375+
)
376+
}
377+
378+
return c.JSON(
379+
http.StatusOK,
380+
AuthTokenEnvelope{
381+
Status: "ok",
382+
Data: &record,
383+
},
384+
)
385+
}

0 commit comments

Comments
 (0)