From 3b8ff9ef63c3b6d883d5f33023e3914d50f22408 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 21 Aug 2026 12:02:06 -0700 Subject: [PATCH] feat: add direct Puzzel client-credentials auth for demo - Adds client_id/client_secret settings so the app can authenticate directly with Puzzel - Adds a temporary script (bin/) for creating demo Edge App instances --- README.md | 11 +++ bin/initialize-puzzel-app-instance | 40 ++++++++++ screenly.yml | 11 +++ screenly_qc.yml | 10 +++ src/constants.ts | 1 + src/credentials.test.ts | 71 +++++++++++++++++- src/credentials.ts | 116 ++++++++++++++++++++++------- 7 files changed, 232 insertions(+), 28 deletions(-) create mode 100755 bin/initialize-puzzel-app-instance diff --git a/README.md b/README.md index f1bfa0c..dfffa3c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ bun run deploy screenly edge-app instance create ``` +Then set the settings needed for the dashboard to show live data (see [Configuration](#configuration) for the full list): + +```bash +screenly edge-app setting set customer_key= +screenly edge-app setting set user_id= +screenly edge-app setting set client_id= +screenly edge-app setting set client_secret= +``` + ## Configuration Settings are defined in `screenly.yml` and mirrored in `screenly_qc.yml` (used for the QC/staging build). @@ -41,6 +50,8 @@ Settings are defined in `screenly.yml` and mirrored in `screenly_qc.yml` (used f | Setting | Description | Required | Default | | ------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | ------- | | `access_token` | For testing only. Production support for Puzzel ID OAuth is not implemented yet. | No | — | +| `client_id` | Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists. | No | — | +| `client_secret` | Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists. | No | — | | `customer_key` | Your Puzzel customer number. | Yes | — | | `display_errors` | For debugging purposes to display errors on the screen. | No | `false` | | `override_locale` | Override the default locale with a supported language code (e.g., en_US, fr_FR, de_DE). | No | `en` | diff --git a/bin/initialize-puzzel-app-instance b/bin/initialize-puzzel-app-instance new file mode 100755 index 0000000..d42ba8a --- /dev/null +++ b/bin/initialize-puzzel-app-instance @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 " >&2 + echo "Example: $0 customer-a 'Customer A'" >&2 + exit 1 +fi + +missing=() +for var in PUZZEL_CUSTOMER_KEY PUZZEL_USER_ID PUZZEL_CLIENT_KEY PUZZEL_CLIENT_SECRET; do + if [ -z "${!var:-}" ]; then + missing+=("${var}") + fi +done + +if [ ${#missing[@]} -gt 0 ]; then + echo "Missing required environment variable(s): ${missing[*]}" >&2 + exit 1 +fi + +slug="$1" +name="$2" +dir="instances/${slug}" + +mkdir -p "${dir}" + +if [ ! -e "${dir}/screenly.yml" ]; then + ln -s "../../screenly.yml" "${dir}/screenly.yml" +fi + +screenly edge-app instance create --name="${name}" -p "${dir}" + +screenly edge-app setting set "customer_key=${PUZZEL_CUSTOMER_KEY}" -p "${dir}" +screenly edge-app setting set "user_id=${PUZZEL_USER_ID}" -p "${dir}" +screenly edge-app setting set "client_id=${PUZZEL_CLIENT_KEY}" -p "${dir}" +screenly edge-app setting set "client_secret=${PUZZEL_CLIENT_SECRET}" -p "${dir}" + +echo +echo "Instance created and configured in ${dir}/." diff --git a/screenly.yml b/screenly.yml index a041dd5..01d7abf 100644 --- a/screenly.yml +++ b/screenly.yml @@ -1,5 +1,6 @@ --- syntax: manifest_v1 +id: 01M0JSRMTH97BP08CQ8Q2ZGNQ3 description: Display real-time Puzzel Contact Centre queue and agent stats on your Screenly digital signage screens categories: - Dashboards @@ -10,6 +11,16 @@ settings: title: Access Token optional: true help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet. + client_id: + type: string + title: Client ID + optional: true + help_text: Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists. + client_secret: + type: secret + title: Client Secret + optional: true + help_text: Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists. customer_key: type: string title: Customer Key diff --git a/screenly_qc.yml b/screenly_qc.yml index a041dd5..97a11e1 100644 --- a/screenly_qc.yml +++ b/screenly_qc.yml @@ -10,6 +10,16 @@ settings: title: Access Token optional: true help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet. + client_id: + type: string + title: Client ID + optional: true + help_text: Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists. + client_secret: + type: secret + title: Client Secret + optional: true + help_text: Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists. customer_key: type: string title: Customer Key diff --git a/src/constants.ts b/src/constants.ts index 4fa8f95..3c4c4dc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ export const API_BASE_URL = 'https://api.puzzel.com/ContactCentre5' +export const PUZZEL_TOKEN_URL = 'https://app.puzzel.com/id/connect/token' export const DEFAULT_REFRESH_INTERVAL_SECONDS = 15 export const TICKER_PERIOD_WINDOW = 'Today' export const VISUAL_QUEUE_RESULT = 'All' diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 1218eea..efd5048 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -10,15 +10,25 @@ import { } from 'bun:test' import { resetScreenlyMock, setupScreenlyMock } from '@screenly/edge-apps/test' import * as utils from '@screenly/edge-apps/utils' -import { fetchAccessToken } from './credentials' +import { clientCredentialsCache, fetchAccessToken } from './credentials' const BASE_SETTINGS = { access_token: '', + client_id: '', + client_secret: '', + customer_key: '12345', + user_id: '67890', display_errors: 'false', screenly_oauth_tokens_url: 'https://api.example.com/oauth/', screenly_app_auth_token: 'app-auth', } +const CLIENT_CREDENTIALS_SETTINGS = { + ...BASE_SETTINGS, + client_id: 'my-client-id', + client_secret: 'secret', +} + const reportError = spyOn(utils, 'reportError') const readEdgeAppCache = spyOn(utils, 'readEdgeAppCache') const writeEdgeAppCache = spyOn(utils, 'writeEdgeAppCache') @@ -46,6 +56,8 @@ describe('fetchAccessToken', () => { reportError.mockReset().mockImplementation(() => {}) readEdgeAppCache.mockReset().mockReturnValue(null) writeEdgeAppCache.mockReset().mockImplementation(() => {}) + clientCredentialsCache.accessToken = '' + clientCredentialsCache.expiresAt = 0 }) afterEach(() => { @@ -65,6 +77,63 @@ describe('fetchAccessToken', () => { expect(fetchMock).not.toHaveBeenCalled() }) + test('exchanges client_id/client_secret for a token, taking priority over access_token', async () => { + setupScreenlyMock( + {}, + { + ...BASE_SETTINGS, + access_token: 'dev-token', + client_id: 'my-client-id', + client_secret: 'my-client-secret', + }, + ) + const fetchMock = fakeResponse(200, { + access_token: 'client-creds-token', + expires_in: 900, + }) + + const token = await fetchAccessToken() + + expect(token).toBe('client-creds-token') + const [requestUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(requestUrl).toBe('https://app.puzzel.com/id/connect/token') + expect(init.body?.toString()).toBe( + new URLSearchParams({ + client_id: 'my-client-id', + client_secret: 'my-client-secret', + scope: 'contact-centre:12345:67890', + grant_type: 'client_credentials', + }).toString(), + ) + }) + + test('reuses a cached client-credentials token instead of refetching', async () => { + setupScreenlyMock({}, CLIENT_CREDENTIALS_SETTINGS) + const fetchMock = fakeResponse(200, { + access_token: 'client-creds-token', + expires_in: 900, + }) + + await fetchAccessToken() + const token = await fetchAccessToken() + + expect(token).toBe('client-creds-token') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('falls back to the cache when the client-credentials exchange fails', async () => { + setupScreenlyMock({}, CLIENT_CREDENTIALS_SETTINGS) + readEdgeAppCache.mockReturnValue({ accessToken: 'cached-token' }) + stubFetch(async () => { + throw new Error('network down') + }) + + const token = await fetchAccessToken() + + expect(token).toBe('cached-token') + expect(reportError).toHaveBeenCalledTimes(1) + }) + test('returns the fetched token and writes it to cache on success', async () => { fakeResponse(200, { token: 'live-token' }) diff --git a/src/credentials.ts b/src/credentials.ts index 12459df..54b1d7c 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -4,42 +4,104 @@ import { reportError, writeEdgeAppCache, } from '@screenly/edge-apps/utils' -import { CACHE_NAMESPACE } from './constants' +import { CACHE_NAMESPACE, PUZZEL_TOKEN_URL } from './constants' type CachedCredentials = { accessToken: string } +interface PuzzelTokenResponse { + access_token?: string + expires_in?: number + error?: string + error_description?: string +} + +export const clientCredentialsCache: { + accessToken: string + expiresAt: number +} = { + accessToken: '', + expiresAt: 0, +} + +async function fetchClientCredentialsToken( + clientId: string, + clientSecret: string, +): Promise { + if (clientCredentialsCache.expiresAt > Date.now()) { + return clientCredentialsCache.accessToken + } + + const customerKey = getSettingWithDefault('customer_key', '') + const userId = getSettingWithDefault('user_id', '') + + const response = await fetch(PUZZEL_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: `contact-centre:${customerKey}:${userId}`, + grant_type: 'client_credentials', + }), + }) + + const body = (await response.json()) as PuzzelTokenResponse + if (!response.ok || !body.access_token) { + throw new Error( + body.error_description ?? + body.error ?? + `Puzzel token request failed (${response.status}).`, + ) + } + + clientCredentialsCache.accessToken = body.access_token + clientCredentialsCache.expiresAt = + Date.now() + (body.expires_in ?? 900) * 1000 - 30_000 + writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', { + accessToken: body.access_token, + }) + return body.access_token +} + +async function fetchBrokerToken(): Promise { + const oauthTokensUrl = String(screenly.settings.screenly_oauth_tokens_url) + const url = new URL( + 'access_token/', + oauthTokensUrl.endsWith('/') ? oauthTokensUrl : `${oauthTokensUrl}/`, + ) + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`, + }, + }) + if (!response.ok) { + throw new Error( + `Screenly returned an unexpected error (${response.status}).`, + ) + } + + const body = (await response.json()) as { token?: string } + if (!body.token) throw new Error('No access token available.') + + writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', { accessToken: body.token }) + return body.token +} + export async function fetchAccessToken(): Promise { + const clientId = getSettingWithDefault('client_id', '') + const clientSecret = getSettingWithDefault('client_secret', '') const devAccessToken = getSettingWithDefault('access_token', '') - if (devAccessToken) return devAccessToken - const displayErrors = getSettingWithDefault('display_errors', 'false') === 'true' + if ((!clientId || !clientSecret) && devAccessToken) return devAccessToken + try { - const oauthTokensUrl = String(screenly.settings.screenly_oauth_tokens_url) - const url = new URL( - 'access_token/', - oauthTokensUrl.endsWith('/') ? oauthTokensUrl : `${oauthTokensUrl}/`, - ) - const response = await fetch(url, { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`, - }, - }) - if (!response.ok) { - throw new Error( - `Screenly returned an unexpected error (${response.status}).`, - ) + if (clientId && clientSecret) { + return await fetchClientCredentialsToken(clientId, clientSecret) } - - const body = (await response.json()) as { token?: string } - if (!body.token) throw new Error('No access token available.') - - writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', { - accessToken: body.token, - }) - return body.token + return await fetchBrokerToken() } catch (err) { const error = err instanceof Error ? err : new Error(String(err)) reportError(error, { source: 'puzzel-credentials' }) @@ -49,6 +111,6 @@ export async function fetchAccessToken(): Promise { CACHE_NAMESPACE, 'credentials', ) - return cached?.accessToken ?? '' + return cached?.accessToken ?? devAccessToken } }