Skip to content

Commit 8be4467

Browse files
committed
feat(apps-sdk): let an app pick up a rotated client secret on its own
Rotation is only useful if nobody has to copy a secret, so the SDK does the half an app can do for itself: authenticate with the credential it holds, ask Tolgee for a fresh one, and write it to the state file in place of the old one. The old secret still authenticates until an operator revokes it, so a failed write costs nothing. `ensureAppCredentialsFresh()` is that on a timer — call it on boot and the credential ages out by itself. Both example apps now do. It is a no-op when the credentials come from the environment, which wins over the state file; a deployment rotates by injecting an issued secret. The new secret is never returned and never logged, and revoking stays with the operator: one replica revoking would cut off its siblings.
1 parent c8f91ee commit 8be4467

8 files changed

Lines changed: 503 additions & 4 deletions

File tree

apps/example-apps/activity-worker/server/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
33
import { fileURLToPath } from 'node:url'
44
import express from 'express'
55
import {
6+
ensureAppCredentialsFresh,
67
renderManifest,
78
selfRegisterApp,
89
tolgeeAppCorsHeaders,
@@ -97,6 +98,34 @@ const connect = async (manifestUrl: string): Promise<void> => {
9798
}
9899
}
99100

101+
102+
/**
103+
* Ages the app's own client secret out on its own, so nobody ever has to copy a
104+
* new one: Tolgee mints it, the SDK stores it, and the secret in use until now
105+
* keeps working until an operator revokes it. No-op when the credentials are
106+
* injected through the environment.
107+
*/
108+
const refreshCredentials = async (): Promise<void> => {
109+
try {
110+
const result = await ensureAppCredentialsFresh({
111+
tolgeeUrl: config.tolgeeUrl,
112+
})
113+
if (result.rotated) {
114+
console.log(
115+
'Auto-connect: this app issued itself a fresh client secret and stored it. ' +
116+
'The previous one still authenticates — revoke it in Tolgee once you see ' +
117+
'it go idle.'
118+
)
119+
}
120+
} catch (error) {
121+
// The credential in use was not touched, so this is a warning, not a failure.
122+
console.warn(
123+
'Could not refresh the stored client secret: ' +
124+
(error instanceof Error ? error.message : String(error))
125+
)
126+
}
127+
}
128+
100129
/**
101130
* Registration is what tells Tolgee where to fetch the manifest, and the dev
102131
* tunnel gets a fresh hostname on every `npm run dev` — so the URLs have to be
@@ -120,6 +149,7 @@ const start = async (): Promise<void> => {
120149
`activity-worker serving ${urls.manifestUrl} (app baseUrl ${urls.baseUrl})`
121150
)
122151
await connect(urls.manifestUrl)
152+
await refreshCredentials()
123153

124154
console.log(
125155
`Watching for translation changes — installations every ${workerConfig.installationsRefreshIntervalMs}ms, ` +

apps/example-apps/keys-showcase/server/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
33
import { fileURLToPath } from 'node:url'
44
import express from 'express'
55
import {
6+
ensureAppCredentialsFresh,
67
renderManifest,
78
selfRegisterApp,
89
tolgeeAppCorsHeaders,
@@ -87,6 +88,34 @@ const connect = async (manifestUrl: string): Promise<void> => {
8788
}
8889
}
8990

91+
92+
/**
93+
* Ages the app's own client secret out on its own, so nobody ever has to copy a
94+
* new one: Tolgee mints it, the SDK stores it, and the secret in use until now
95+
* keeps working until an operator revokes it. No-op when the credentials are
96+
* injected through the environment.
97+
*/
98+
const refreshCredentials = async (): Promise<void> => {
99+
try {
100+
const result = await ensureAppCredentialsFresh({
101+
tolgeeUrl: config.tolgeeUrl,
102+
})
103+
if (result.rotated) {
104+
console.log(
105+
'Auto-connect: this app issued itself a fresh client secret and stored it. ' +
106+
'The previous one still authenticates — revoke it in Tolgee once you see ' +
107+
'it go idle.'
108+
)
109+
}
110+
} catch (error) {
111+
// The credential in use was not touched, so this is a warning, not a failure.
112+
console.warn(
113+
'Could not refresh the stored client secret: ' +
114+
(error instanceof Error ? error.message : String(error))
115+
)
116+
}
117+
}
118+
90119
/**
91120
* Registration is what tells Tolgee where to fetch the manifest, and the dev
92121
* tunnel gets a fresh hostname on every `npm run dev` — so the URLs have to be
@@ -107,6 +136,7 @@ const start = async (): Promise<void> => {
107136
const urls = applyUrlOverrides(resolved)
108137
console.log(`keys-showcase serving ${urls.manifestUrl} (app baseUrl ${urls.baseUrl})`)
109138
await connect(urls.manifestUrl)
139+
await refreshCredentials()
110140
}
111141

112142
app.listen(config.serverPort, () => {

apps/tolgee-apps-sdk/README.md

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ body {
161161

162162
Call `app.dispose()` when your UI unmounts.
163163

164-
## Server: the two auth flows
164+
## Server: the auth flows
165165

166166
### 1. `selfRegisterApp` — register without the UI
167167

@@ -213,6 +213,8 @@ Nothing to copy, and **never print the secret**. Log `credentialsPath` instead.
213213
- Records are keyed by Tolgee instance, so credentials issued by one instance
214214
are never handed to another.
215215
- A re-registration that returns `clientSecret: null` keeps the stored secret.
216+
- `secretIssuedAt` records when Tolgee issued the stored secret, which is what
217+
`ensureAppCredentialsFresh()` ages out.
216218
- Writes go through a temp file and a rename, so an interrupted or concurrent
217219
write cannot leave a half-written file behind; an unreadable file reads as
218220
"nothing stored" rather than throwing.
@@ -255,7 +257,62 @@ const { data, error } = await tolgee.GET('/v2/projects/{projectId}/activity', {
255257
Inside an iframe you don't need this at all: the install token from
256258
`TolgeeAppContext` already authenticates calls as the install + user.
257259

258-
### 3. `fetchAppInstallations` — what am I installed for?
260+
### 3. `rotateAppClientSecret` — replace the secret without anyone copying it
261+
262+
A client secret ends up in the hands of whoever set the app up. When that person
263+
leaves, the organization needs the old credential dead — without deleting the
264+
install, which would take its granted scopes, its availability and every
265+
per-project enablement with it.
266+
267+
Rotation is therefore two deliberate steps, and an install may hold **several
268+
live secrets at once** (up to five):
269+
270+
1. **Issue.** A new secret is minted. Every existing one keeps working.
271+
2. **Revoke.** The old one is invalidated, on the operator's schedule, once
272+
Tolgee's `lastUsedAt` shows nothing is using it any more.
273+
274+
Step one is the app's own job, and needs no human:
275+
276+
```ts
277+
import { rotateAppClientSecret } from '@tolgee/apps-sdk/server'
278+
279+
await rotateAppClientSecret()
280+
```
281+
282+
The call authenticates with the secret the app already holds, asks Tolgee for a
283+
new one, and writes it to the state file in place of the old one — atomically,
284+
and **never returned and never logged**. The previous secret still
285+
authenticates, so a failed write leaves the app running on what it had.
286+
287+
`ensureAppCredentialsFresh()` is the same thing on a timer, meant for boot:
288+
289+
```ts
290+
await selfRegisterApp({ ... })
291+
await ensureAppCredentialsFresh() // rotates only if the stored secret is > 30 days old
292+
```
293+
294+
Pass `{ maxAgeMs }` to change the age. It reports rather than throws when there
295+
is nothing to do, and it is a **no-op when the credentials come from
296+
`TOLGEE_APP_CLIENT_ID` / `TOLGEE_APP_CLIENT_SECRET`** — those win over the state
297+
file, so rotating would store a secret the app would never read. Rotate a
298+
deployment by issuing a secret in Tolgee and injecting it.
299+
300+
Run several replicas off one install? Only one of them should rotate: every call
301+
mints another secret, and Tolgee caps how many an install may hold.
302+
303+
Revoking is not the SDK's to do — one replica revoking would cut off its
304+
siblings — so step two happens in Tolgee, under **Organization → Apps** (or
305+
**Administration → Apps** for a native app). An app that genuinely owns its own
306+
lifecycle can still call `DELETE /v2/apps/self/secrets/{id}`; Tolgee refuses to
307+
let it revoke its own last live secret, which would lock it out permanently.
308+
309+
> **A leaked secret is recovered from per install.** There is no publisher
310+
> identity behind a distributed app — every install has credentials of its own
311+
> and there is nobody to authenticate as across all of them. The recourse for a
312+
> mass leak is to rotate each install, which is what the self-service endpoints
313+
> above exist to make scriptable.
314+
315+
### 4. `fetchAppInstallations` — what am I installed for?
259316

260317
An app backend with no iframe and no user has no idea which projects it may
261318
touch: an org admin makes the app available, a project owner enables it, and
@@ -360,7 +417,8 @@ points).
360417

361418
**`@tolgee/apps-sdk/server`**`renderManifest()`, `tolgeeAppCorsHeaders()`,
362419
`decodeContextToken()`, `loadTolgeeAppConfig()`, `selfRegisterApp()`,
363-
`fetchAppAccessToken()`, `createTolgeeAppServerClient()`,
420+
`fetchAppAccessToken()`, `rotateAppClientSecret()`,
421+
`ensureAppCredentialsFresh()`, `createTolgeeAppServerClient()`,
364422
`fetchAppInstallations()` (`AppInstallation`,
365423
`AppEnabledProject`, `AppInstallationOrganization`, `AppInstallationsInput`),
366424
`appInstallStatePath()`, `readStoredAppInstall()`, `saveAppInstall()`.

apps/tolgee-apps-sdk/src/server/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export type TolgeeAppConfig = {
2525
clientSecret: string | null
2626
/** Install the stored credentials belong to; null when nothing is stored. */
2727
installId: number | null
28+
/**
29+
* When the stored client secret was issued, or null when it came from the
30+
* environment or predates this being recorded. See `ensureAppCredentialsFresh`.
31+
*/
32+
secretIssuedAt: string | null
2833
credentialsSource: TolgeeAppCredentialsSource
2934
}
3035

@@ -59,6 +64,7 @@ export const loadTolgeeAppConfig = (
5964
clientId: fromEnv ? envClientId : (stored?.clientId ?? null),
6065
clientSecret: fromEnv ? envClientSecret : (stored?.clientSecret ?? null),
6166
installId: stored?.installId ?? null,
67+
secretIssuedAt: fromEnv ? null : (stored?.secretIssuedAt ?? null),
6268
credentialsSource: fromEnv ? 'env' : storedCredentials ? 'stored' : null,
6369
}
6470
}

apps/tolgee-apps-sdk/src/server/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ export type {
1515
} from './installStore'
1616
export { selfRegisterApp } from './selfRegisterApp'
1717
export type { SelfRegisterInput, SelfRegisterResult } from './selfRegisterApp'
18+
export {
19+
ensureAppCredentialsFresh,
20+
rotateAppClientSecret,
21+
} from './rotateAppClientSecret'
22+
export type {
23+
EnsureAppCredentialsInput,
24+
EnsureAppCredentialsResult,
25+
RotateAppClientSecretInput,
26+
RotatedAppClientSecret,
27+
} from './rotateAppClientSecret'
1828
export { fetchAppAccessToken } from './fetchAppAccessToken'
1929
export type { AppAccessToken, AppAccessTokenInput } from './fetchAppAccessToken'
2030
export { createTolgeeAppServerClient } from './client'

apps/tolgee-apps-sdk/src/server/installStore.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export type StoredAppInstall = {
2828
/** True when the install belongs to no organization. */
2929
native: boolean
3030
organizationSlug: string | null
31+
/**
32+
* ISO timestamp of when Tolgee issued `clientSecret`, or null when it was
33+
* stored before this was recorded. Drives `ensureAppCredentialsFresh()`.
34+
*/
35+
secretIssuedAt: string | null
3136
/** ISO timestamp of the last write. */
3237
updatedAt: string
3338
}
@@ -39,6 +44,8 @@ export type AppInstallRecord = {
3944
clientSecret?: string | null
4045
native?: boolean
4146
organizationSlug?: string | null
47+
/** Defaults to now whenever the record carries a `clientSecret`. */
48+
secretIssuedAt?: string | null
4249
}
4350

4451
type StateFile = {
@@ -80,6 +87,7 @@ export const saveAppInstall = (
8087
const carried =
8188
previous && isSameInstall(previous, record) ? previous : undefined
8289

90+
const now = new Date().toISOString()
8391
const stored: StoredAppInstall = {
8492
tolgeeUrl: key,
8593
installId: record.installId,
@@ -88,7 +96,10 @@ export const saveAppInstall = (
8896
native: record.native ?? carried?.native ?? false,
8997
organizationSlug:
9098
record.organizationSlug ?? carried?.organizationSlug ?? null,
91-
updatedAt: new Date().toISOString(),
99+
secretIssuedAt:
100+
record.secretIssuedAt ??
101+
(record.clientSecret != null ? now : (carried?.secretIssuedAt ?? null)),
102+
updatedAt: now,
92103
}
93104

94105
state.installs[key] = stored
@@ -116,6 +127,8 @@ const asStoredInstall = (
116127
native: raw.native === true,
117128
organizationSlug:
118129
typeof raw.organizationSlug === 'string' ? raw.organizationSlug : null,
130+
secretIssuedAt:
131+
typeof raw.secretIssuedAt === 'string' ? raw.secretIssuedAt : null,
119132
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '',
120133
}
121134
}

0 commit comments

Comments
 (0)