diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 67e9c9f..5f58023 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -19,6 +19,7 @@ import OIDCDiscoveryConstants from './constants/OIDCDiscoveryConstants'; import OIDCRequestConstants from './constants/OIDCRequestConstants'; import PKCEConstants from './constants/PKCEConstants'; +import TokenConstants from './constants/TokenConstants'; import VendorConstants from './constants/VendorConstants'; import {DefaultCacheStore} from './DefaultCacheStore'; import {DefaultCrypto} from './DefaultCrypto'; @@ -36,7 +37,7 @@ import {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; import {OIDCEndpoints} from './models/oidc-endpoints'; import {SessionData, UserSession} from './models/session'; import {Storage, TemporaryStore} from './models/store'; -import {IdToken, TokenExchangeRequestConfig, TokenResponse} from './models/token'; +import {AccessTokenApiResponse, IdToken, TokenExchangeRequestConfig, TokenResponse} from './models/token'; import {User, UserProfile} from './models/user'; import StorageManager from './StorageManager'; import AuthenticationHelper from './utils/AuthenticationHelper'; @@ -182,9 +183,36 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { } public async getAccessToken(sessionId?: string): Promise { + const configData = await this.configProvider(); + + if (configData.grantType === 'client_credentials') { + const sessionData = await this.storageManager.getSessionData(sessionId); + + if (sessionData?.access_token && this.isCachedTokenStillValid(sessionData)) { + return sessionData.access_token; + } + + const tokenResponse = await this.requestClientCredentialsToken(sessionId); + + return tokenResponse.accessToken; + } + return (await this.storageManager.getSessionData(sessionId))?.access_token; } + private isCachedTokenStillValid(sessionData: SessionData): boolean { + if (!sessionData.created_at || !sessionData.expires_in) { + return false; + } + + const expiresInMs = parseInt(sessionData.expires_in, 10) * 1000; + + return ( + sessionData.created_at + expiresInMs - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > + Date.now() + ); + } + public clearSession(sessionId?: string): void { this.authHelper.clearSession(sessionId); } @@ -746,6 +774,107 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { return this.authHelper.handleTokenResponse(tokenResponse, userId); } + /** + * Requests a machine-to-machine access token via the `client_credentials` grant + * (RFC 6749 §4.4) and caches it under the given session key. Unlike the user-facing + * flows, there's no id_token or refresh_token: the service authenticates as itself. + */ + protected async requestClientCredentialsToken(sessionId?: string): Promise { + if ( + !(await this.storageManager.getTemporaryDataParameter( + OIDCDiscoveryConstants.Storage.StorageKeys.OPENID_PROVIDER_CONFIG_INITIATED, + )) + ) { + await this.loadOpenIDProviderConfiguration(false); + } + + const tokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).token_endpoint; + const configData = await this.configProvider(); + + if (!tokenEndpoint || tokenEndpoint.trim().length === 0) { + throw new ThunderIDAuthException( + 'JS-AUTH_CORE-RCCT-NF01', + 'Token endpoint not found.', + 'No token endpoint was found in the OIDC provider meta data returned by the well-known endpoint ' + + 'or the token endpoint passed to the SDK is empty.', + ); + } + + const body: URLSearchParams = new URLSearchParams(); + + body.set('grant_type', 'client_credentials'); + body.set('client_id', configData.clientId ?? ''); + + const hasSecret = Boolean(configData.clientSecret && configData.clientSecret.trim().length > 0); + const tokenEndpointAuthMethod = configData.tokenRequest?.authMethod ?? 'client_secret_basic'; + + if (hasSecret && tokenEndpointAuthMethod === 'client_secret_post') { + body.set('client_secret', configData.clientSecret!); + } + + const scope = Array.isArray(configData.scopes) ? configData.scopes.join(' ') : configData.scopes; + + if (scope) { + body.set('scope', scope); + } + + if (configData.tokenRequest?.params) { + Object.entries(configData.tokenRequest.params).forEach(([key, value]: [string, unknown]) => { + body.set(key, value as string); + }); + } + + const tokenRequestHeaders: Record = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (hasSecret && tokenEndpointAuthMethod === 'client_secret_basic') { + const credential = `${encodeURIComponent(configData.clientId!)}:${encodeURIComponent(configData.clientSecret!)}`; + tokenRequestHeaders['Authorization'] = `Basic ${base64Encode(credential)}`; + } + + let tokenResponse: Response; + + try { + tokenResponse = await fetch(tokenEndpoint, { + body, + headers: tokenRequestHeaders, + method: 'POST', + }); + } catch (error: any) { + throw new ThunderIDAuthException( + 'JS-AUTH_CORE-RCCT-NE02', + 'Requesting client credentials token failed.', + error ?? 'The request to get the client credentials access token failed.', + ); + } + + if (!tokenResponse.ok) { + throw new ThunderIDAuthException( + 'JS-AUTH_CORE-RCCT-HE03', + `Requesting client credentials token failed with ${tokenResponse.statusText}`, + (await tokenResponse.json()) as string, + ); + } + + const parsedResponse = (await tokenResponse.json()) as AccessTokenApiResponse; + + parsedResponse.created_at = new Date().getTime(); + + await this.storageManager.setSessionData(parsedResponse, sessionId); + + return { + accessToken: parsedResponse.access_token, + createdAt: parsedResponse.created_at, + expiresIn: parsedResponse.expires_in, + idToken: parsedResponse.id_token ?? '', + refreshToken: parsedResponse.refresh_token ?? '', + scope: parsedResponse.scope, + tokenType: parsedResponse.token_type, + }; + } + protected async revokeAccessToken(userId?: string): Promise { const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint; const configData = await this.configProvider(); diff --git a/packages/javascript/src/constants/TokenConstants.ts b/packages/javascript/src/constants/TokenConstants.ts index 5b4fe7e..287592a 100644 --- a/packages/javascript/src/constants/TokenConstants.ts +++ b/packages/javascript/src/constants/TokenConstants.ts @@ -44,6 +44,9 @@ const TokenConstants: { readonly REFRESH_TOKEN_TIMER: string; }; }; + readonly Lifecycle: { + readonly CLIENT_CREDENTIALS_REFRESH_MARGIN_MS: number; + }; } = { /** * Token signature validation constants. @@ -83,6 +86,18 @@ const TokenConstants: { REFRESH_TOKEN_TIMER: 'refresh_token_timer', }, }, + + /** + * Token lifecycle timing constants. + * Contains timing thresholds used to decide when cached tokens should be refreshed. + */ + Lifecycle: { + /** + * How far ahead of expiry a cached `client_credentials` token is treated as + * stale and refreshed. + */ + CLIENT_CREDENTIALS_REFRESH_MARGIN_MS: 30_000, + }, } as const; export default TokenConstants; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 4cc0741..23db89e 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -267,6 +267,19 @@ export interface BaseConfig extends WithPreferences, WithExtensions */ mode?: 'redirect' | 'embedded'; + /** + * The OAuth 2.0 grant type this client uses to obtain access tokens. + * + * - `'authorization_code'` (default) — standard user sign-in flow. `getAccessToken()` + * only ever reads from the stored session; it never fetches a token on its own. + * - `'client_credentials'` — machine-to-machine flow (RFC 6749 §4.4). There's no user + * and no sign-in step: `getAccessToken()` fetches (and transparently caches/refreshes) + * a token for the service itself, using `clientId`/`clientSecret`. + * + * @default 'authorization_code' + */ + grantType?: 'authorization_code' | 'client_credentials'; + /** * Configuration for chaining authentication across multiple organization contexts. * Used when you need to authenticate a user in one organization using credentials diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26d5917..426bf13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -925,6 +925,18 @@ importers: specifier: ^5.9.3 version: 5.9.3 + samples/node/quickstart: + dependencies: + '@thunderid/node': + specifier: workspace:* + version: link:../../../packages/node + boxen: + specifier: ^8.0.1 + version: 8.0.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + samples/nuxt/quickstart: dependencies: '@thunderid/nuxt': @@ -1165,10 +1177,18 @@ packages: resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} engines: {node: '>= 20.12.0'} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + '@clack/prompts@1.6.0': resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -4141,6 +4161,9 @@ packages: alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4361,6 +4384,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -4426,6 +4453,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -4443,6 +4474,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -4461,6 +4496,10 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -7432,6 +7471,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -8031,6 +8074,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8361,6 +8408,11 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@clack/prompts@1.6.0': dependencies: '@clack/core': 1.4.2 @@ -8368,6 +8420,13 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.4.2': {} '@colordx/core@5.4.3': {} @@ -9018,7 +9077,7 @@ snapshots: '@nuxt/cli@3.36.0(@nuxt/schema@3.21.7)(cac@6.7.14)(magicast@0.5.3)': dependencies: '@bomb.sh/tab': 0.0.16(cac@6.7.14)(citty@0.2.2) - '@clack/prompts': 1.6.0 + '@clack/prompts': 1.7.0 c12: 3.3.4(magicast@0.5.3) citty: 0.2.2 confbox: 0.2.4 @@ -9056,7 +9115,7 @@ snapshots: '@nuxt/cli@3.36.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.3)': dependencies: '@bomb.sh/tab': 0.0.16(cac@6.7.14)(citty@0.2.2) - '@clack/prompts': 1.6.0 + '@clack/prompts': 1.7.0 c12: 3.3.4(magicast@0.5.3) citty: 0.2.2 confbox: 0.2.4 @@ -11240,6 +11299,10 @@ snapshots: alien-signals@3.2.1: {} + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -11489,6 +11552,17 @@ snapshots: boolbase@1.0.0: {} + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -11584,6 +11658,8 @@ snapshots: callsites@3.1.0: {} + camelcase@8.0.0: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.28.2 @@ -11605,6 +11681,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -11621,6 +11699,8 @@ snapshots: citty@0.2.2: {} + cli-boxes@3.0.0: {} + client-only@0.0.1: {} cliui@9.0.1: @@ -15495,6 +15575,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -16242,6 +16324,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/samples/express/quickstart/index.mjs b/samples/express/quickstart/index.mjs index 86e1947..178014e 100644 --- a/samples/express/quickstart/index.mjs +++ b/samples/express/quickstart/index.mjs @@ -64,17 +64,20 @@ async function getSession(req) { function renderConfigNeeded() { return layout({ title: 'Configuration needed', - body: `
-
Setup required
-

Configuration needed

-

This quickstart can't reach ThunderID yet. Set the following environment - variable(s), then restart the server.

-
    - ${missingEnvVars.map((key) => `
  • ${esc(key)}
  • `).join('')} -
-

Copy .env.example to .env, fill in the values from - your ThunderID application, then run npm start again.

-
`, + body: `
+
+
${thunderMark(40)}
+
Setup required
+

Configuration needed

+

This quickstart can't reach ThunderID yet. Set the following environment + variable(s), then restart the server.

+
    + ${missingEnvVars.map((key) => `
  • ${esc(key)}
  • `).join('')} +
+

Copy .env.example to .env, fill in the values from + your ThunderID application, then run npm run dev again.

+
+
`, }); } diff --git a/samples/express/quickstart/package.json b/samples/express/quickstart/package.json index ebd669e..1e9a3b1 100644 --- a/samples/express/quickstart/package.json +++ b/samples/express/quickstart/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "start": "node --env-file=.env index.mjs", - "dev": "node --env-file=.env --watch index.mjs" + "start": "node --env-file-if-exists=.env index.mjs", + "dev": "node --env-file-if-exists=.env --watch index.mjs" }, "dependencies": { "@thunderid/express": "workspace:*", diff --git a/samples/express/quickstart/public/styles.css b/samples/express/quickstart/public/styles.css index 5a3a5ca..d16c159 100644 --- a/samples/express/quickstart/public/styles.css +++ b/samples/express/quickstart/public/styles.css @@ -365,6 +365,10 @@ button { flex-shrink: 0; } +.config-badge { + color: #e88b3a; +} + .hero-title { font-size: 42px; font-weight: 700; @@ -630,6 +634,7 @@ button { /* ── Config notice ── */ .config-list { + text-align: left; list-style: none; background: var(--color-card); border: 1px solid var(--color-border); @@ -650,6 +655,22 @@ button { border-bottom: 1px solid var(--color-border); } +.config-hint { + font-size: 14px; + color: var(--color-muted); + line-height: 1.7; + max-width: 420px; + margin: 0 auto; +} + +.config-hint code { + background: rgba(54, 136, 255, 0.08); + border-radius: 4px; + padding: 2px 6px; + font-family: var(--font-mono); + font-size: 13px; +} + /* ── Token debug page ── */ .token-header { display: flex; diff --git a/samples/node/quickstart/.env.example b/samples/node/quickstart/.env.example new file mode 100644 index 0000000..b900679 --- /dev/null +++ b/samples/node/quickstart/.env.example @@ -0,0 +1,7 @@ +THUNDERID_CLIENT_ID=your-agent-client-id +THUNDERID_CLIENT_SECRET=your-agent-client-secret +THUNDERID_BASE_URL=https://localhost:8090 +# Optional: space-separated scopes to request for this service. +THUNDERID_SCOPE= +# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. +NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/node/quickstart/README.md b/samples/node/quickstart/README.md new file mode 100644 index 0000000..c96928d --- /dev/null +++ b/samples/node/quickstart/README.md @@ -0,0 +1,76 @@ +# ThunderID Node.js Service Quickstart + +A minimal machine-to-machine (service-to-service) client, using the ThunderID Node.js SDK +(`@thunderid/node`). Unlike the other quickstarts, there's no user and no browser sign-in: this +service authenticates as **itself** with the OAuth 2.0 `client_credentials` grant, then uses the +resulting access token to call another piece of business logic. + +This is the pattern to use for background jobs, cron tasks, or one backend service calling +another, where there's no human in the loop to redirect through a login page. + +The sample is organized the way a real backend usually is, with authentication kept in its own +service instead of scattered across business logic: + +```text +node/quickstart/ +├── index.mjs Wires the services together and prints the result +└── lib/ + ├── AuthService.mjs Acquires and caches this service's own access token + ├── InventoryService.mjs Business logic; asks AuthService for a token before answering + └── ui.mjs Terminal output, built on boxen and picocolors +``` + +## How it works + +- `lib/AuthService.mjs` initializes `ThunderIDNodeClient` with `grantType: 'client_credentials'`. + With that set, `getAccessToken()` authenticates as the service itself (no session, no sign-in) + and transparently fetches, caches, and refreshes the token, the same method a user-facing + quickstart would call to read an existing session's token, just authenticating as the service + itself instead of a signed-in user. +- `lib/InventoryService.mjs` is the business logic that needs a token before it can act. It asks + `AuthService` for one; the rest of the app never handles a token or an `Authorization` header + directly. In a real deployment, `InventoryService` would send that token to a separate inventory + backend over HTTP, in place of another service calling `AuthService`'s owner the same way. + +## Prerequisites + +- Node.js 18+ +- A running ThunderID instance (default: `https://localhost:8090`) +- An **agent** registered in ThunderID with the `client_credentials` grant + +## Create an agent + +Agents are ThunderID's machine identities, distinct from user-facing applications. + +1. Open the ThunderID Console (`https://localhost:8090/console`) and go to **Agents**. +2. Create a new agent and give it a name (e.g. `node-service-quickstart`). +3. Under its OAuth 2.0 settings, enable the `client_credentials` grant type and set the token + endpoint auth method to `client_secret_basic`. +4. Copy the generated client ID and client secret, you'll need them below. + +## Getting started + +1. Copy the environment file and fill in your agent's credentials: + ```sh + cp .env.example .env + ``` + ``` + THUNDERID_CLIENT_ID= + THUNDERID_CLIENT_SECRET= + THUNDERID_BASE_URL=https://localhost:8090 + ``` + +2. Install dependencies and run it: + ```sh + npm install + npm start + ``` + +The script authenticates once, then calls `InventoryService.getStock()` for a few SKUs and prints +the result before exiting: a scope line, then an `Inventory` panel with one line per SKU, green +for in-stock items, yellow for out-of-stock, red for a SKU that doesn't exist. + +## Learn more + +- [ThunderID Docs](https://thunderid.dev/docs) +- [OAuth 2.0 Client Credentials Grant (RFC 6749 §4.4)](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) diff --git a/samples/node/quickstart/index.mjs b/samples/node/quickstart/index.mjs new file mode 100644 index 0000000..8d01c87 --- /dev/null +++ b/samples/node/quickstart/index.mjs @@ -0,0 +1,42 @@ +import {AuthService} from './lib/AuthService.mjs'; +import {InventoryService} from './lib/InventoryService.mjs'; +import {printIntro, printConfigNeeded, printAuthenticated, printInventory} from './lib/ui.mjs'; + +printIntro(); + +const REQUIRED_ENV_VARS = ['THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET']; +const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]); + +if (missingEnvVars.length > 0) { + printConfigNeeded(missingEnvVars); + process.exit(1); +} + +const THUNDER_BASE_URL = process.env.THUNDERID_BASE_URL || 'https://localhost:8090'; + +// AuthService authenticates as this service itself (grantType: 'client_credentials'), +// no user and no browser sign-in, unlike the express/react/etc quickstarts, which +// authenticate a human through a login page. +const auth = new AuthService({ + baseUrl: THUNDER_BASE_URL, + clientId: process.env.THUNDERID_CLIENT_ID, + clientSecret: process.env.THUNDERID_CLIENT_SECRET, + scopes: process.env.THUNDERID_SCOPE, +}); + +const inventory = new InventoryService({auth}); + +const accessToken = await auth.getAccessToken(); +const {scope} = await auth.decodeAccessToken(accessToken); +printAuthenticated({baseUrl: THUNDER_BASE_URL, clientId: process.env.THUNDERID_CLIENT_ID, scope}); + +const rows = []; +for (const sku of ['SKU-100', 'SKU-200', 'SKU-300', 'SKU-999']) { + try { + const item = await inventory.getStock(sku); + rows.push({sku, name: item.name, stock: item.stock}); + } catch (error) { + rows.push({sku, error: error.message}); + } +} +printInventory(rows); diff --git a/samples/node/quickstart/lib/AuthService.mjs b/samples/node/quickstart/lib/AuthService.mjs new file mode 100644 index 0000000..e827c15 --- /dev/null +++ b/samples/node/quickstart/lib/AuthService.mjs @@ -0,0 +1,33 @@ +import {ThunderIDNodeClient} from '@thunderid/node'; + +/** + * Authenticates this service as itself with the OAuth 2.0 client_credentials + * grant, no user, no browser sign-in. Other services in this process (or a + * real deployment) ask AuthService for a token instead of talking to + * ThunderIDNodeClient directly, so the authentication concern stays in one + * place and out of business logic like InventoryService. + */ +export class AuthService { + #client = new ThunderIDNodeClient(); + #ready; + + constructor({baseUrl, clientId, clientSecret, scopes}) { + this.#ready = this.#client.initialize({ + baseUrl, + clientId, + clientSecret, + grantType: 'client_credentials', + scopes, + }); + } + + /** Fetches (or reuses the cached) access token for this service's own identity. */ + async getAccessToken() { + await this.#ready; + return this.#client.getAccessToken(); + } + + async decodeAccessToken(accessToken) { + return this.#client.decodeJwtToken(accessToken); + } +} diff --git a/samples/node/quickstart/lib/InventoryService.mjs b/samples/node/quickstart/lib/InventoryService.mjs new file mode 100644 index 0000000..9897c79 --- /dev/null +++ b/samples/node/quickstart/lib/InventoryService.mjs @@ -0,0 +1,29 @@ +const CATALOG = { + 'SKU-100': {sku: 'SKU-100', name: 'Wireless Mouse', stock: 42}, + 'SKU-200': {sku: 'SKU-200', name: 'Mechanical Keyboard', stock: 7}, + 'SKU-300': {sku: 'SKU-300', name: '4K Monitor', stock: 0}, +}; + +/** + * Business logic for a service that needs to call another backend. It asks + * AuthService for a token before answering, so the rest of the app never + * handles a token or an Authorization header directly. In a real deployment + * this would send the token to a separate inventory service over HTTP + * instead of just holding it in-process. + */ +export class InventoryService { + constructor({auth}) { + this.auth = auth; + } + + async getStock(sku) { + // A real backend would attach this as `Authorization: Bearer ${accessToken}` + // on the request to the inventory service; here it just proves the token + // was obtained before answering. + await this.auth.getAccessToken(); + + const item = CATALOG[sku]; + if (!item) throw new Error(`No item with SKU ${sku}`); + return item; + } +} diff --git a/samples/node/quickstart/lib/ui.mjs b/samples/node/quickstart/lib/ui.mjs new file mode 100644 index 0000000..cc52067 --- /dev/null +++ b/samples/node/quickstart/lib/ui.mjs @@ -0,0 +1,82 @@ +import boxen from 'boxen'; +import pc from 'picocolors'; + +const BRAND = '#3688FF'; +const ORANGE = '#e88b3a'; + +function indent(text) { + return text + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} + +function badge(label, colorFn) { + const line = pc.dim('──────'); + return `${line} ${pc.bold(colorFn(label.toUpperCase()))} ${line}`; +} + +function box(content, {title, borderColor}) { + return boxen(content, { + title, + titleAlignment: 'left', + padding: {top: 0, bottom: 0, left: 1, right: 1}, + borderStyle: 'round', + borderColor, + }); +} + +export function printIntro() { + console.log(); + console.log(indent(pc.bold('ThunderID Node Service Quickstart'))); + console.log(indent(pc.dim('Service-to-service auth via OAuth 2.0 client_credentials'))); + console.log(); +} + +export function printConfigNeeded(missingEnvVars) { + console.log(indent(badge('Setup required', pc.yellow))); + console.log(); + console.log(indent(pc.bold('Configuration needed'))); + console.log( + indent( + pc.dim( + "This quickstart can't reach ThunderID yet. Set the following environment variable(s), then run again.", + ), + ), + ); + console.log(); + console.log(indent(box(missingEnvVars.map((key) => pc.red(key)).join('\n'), {borderColor: ORANGE}))); + console.log(); + console.log( + indent( + pc.dim('Copy ') + + pc.cyan('.env.example') + + pc.dim(' to ') + + pc.cyan('.env') + + pc.dim(', fill in the client credentials for your agent, then run ') + + pc.cyan('npm start') + + pc.dim(' again.'), + ), + ); + console.log(); +} + +export function printAuthenticated({baseUrl, clientId, scope}) { + console.log(indent(`${pc.bgGreen(pc.black(' ● AUTHENTICATED '))} ${pc.dim('client_credentials grant')}`)); + console.log(); + console.log(indent(`${pc.dim('BASE URL')} ${baseUrl}`)); + console.log(indent(`${pc.dim('CLIENT ID')} ${clientId}`)); + console.log(indent(`${pc.dim('SCOPE')} ${scope || pc.dim('(none requested)')}`)); + console.log(); +} + +export function printInventory(rows) { + const lines = rows.map(({sku, name, stock, error}) => { + const label = pc.bold(sku.padEnd(9)); + if (error) return `${label} ${pc.red(error)}`; + const stockText = stock > 0 ? pc.green(`${stock} in stock`) : pc.yellow('out of stock'); + return `${label} ${name.padEnd(20)} ${stockText}`; + }); + console.log(indent(box(lines.join('\n'), {title: 'Inventory', borderColor: BRAND}))); + console.log(); +} diff --git a/samples/node/quickstart/package.json b/samples/node/quickstart/package.json new file mode 100644 index 0000000..77d90cc --- /dev/null +++ b/samples/node/quickstart/package.json @@ -0,0 +1,15 @@ +{ + "name": "@thunderid/node-quickstart", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "node --env-file-if-exists=.env index.mjs", + "dev": "node --env-file-if-exists=.env --watch index.mjs" + }, + "dependencies": { + "@thunderid/node": "workspace:*", + "boxen": "^8.0.1", + "picocolors": "^1.1.1" + } +}