diff --git a/src/modules/sso.ts b/src/modules/sso.ts index ca6be3b..c947878 100644 --- a/src/modules/sso.ts +++ b/src/modules/sso.ts @@ -6,7 +6,6 @@ import { SsoModule } from "./sso.types"; * * @param axios - Axios instance * @param appId - Application ID - * @param userToken - User authentication token * @returns SSO module with authentication methods * @internal */ @@ -20,5 +19,11 @@ export function createSsoModule( const url = `/apps/${appId}/auth/sso/accesstoken/${userid}`; return axios.get(url); }, + + // Get the stored SSO OIDC ID token for a specific user + async getIdToken(userid: string) { + const url = `/apps/${appId}/auth/sso/idtoken/${userid}`; + return axios.get(url); + }, }; } diff --git a/src/modules/sso.types.ts b/src/modules/sso.types.ts index ecb7459..023bfb4 100644 --- a/src/modules/sso.types.ts +++ b/src/modules/sso.types.ts @@ -1,5 +1,3 @@ -import { AxiosResponse } from "axios"; - /** * Response from SSO access token endpoint. * @internal @@ -11,9 +9,9 @@ export interface SsoAccessTokenResponse { /** * SSO (Single Sign-On) module for managing SSO authentication. * - * This module provides methods for retrieving SSO access tokens for users. - * These tokens allow you to authenticate Base44 users with external - * systems or services. + * This module provides methods for retrieving SSO tokens for users. These + * tokens allow you to authenticate Base44 users with external systems or + * services. * * This module is only available to use with a client in service role authentication mode, which means it can only be used in backend environments. * @@ -44,4 +42,29 @@ export interface SsoModule { * ``` */ getAccessToken(userid: string): Promise; + + /** + * Gets the stored SSO OIDC ID token for the current app user. + * + * The service-role client must include an on-behalf-of token for the same + * user specified by `userid`. This method returns the stored token as-is and + * does not refresh it. + * + * @param userid - The current app user's ID. + * @returns Promise resolving to the raw ID-token string. + * + * @example + * ```typescript + * import { createClientFromRequest } from 'npm:@base44/sdk'; + * + * Deno.serve(async (req) => { + * const base44 = createClientFromRequest(req); + * const user = await base44.auth.me(); + * const idToken = await base44.asServiceRole.sso.getIdToken(user.id); + * + * return Response.json({ idToken }); + * }); + * ``` + */ + getIdToken(userid: string): Promise; } diff --git a/tests/unit/sso.test.ts b/tests/unit/sso.test.ts new file mode 100644 index 0000000..04fe2c5 --- /dev/null +++ b/tests/unit/sso.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("SSO module", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const serviceToken = "service-token-123"; + const userToken = "user-token-456"; + const userId = "user_123"; + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ + serverUrl, + appId, + token: userToken, + serviceToken, + }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("getIdToken issues the app-scoped GET request and returns the raw token", async () => { + const rawIdToken = "header.payload.signature"; + + scope + .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) + .reply(200, JSON.stringify(rawIdToken), { + "Content-Type": "application/json", + }); + + const idToken: string = + await base44.asServiceRole.sso.getIdToken(userId); + + expect(idToken).toBe(rawIdToken); + expect(scope.isDone()).toBe(true); + }); + + test("getAccessToken issues the existing GET request and returns the raw token", async () => { + const rawAccessToken = "access-token-123"; + + scope + .get(`/api/apps/${appId}/auth/sso/accesstoken/${userId}`) + .reply(200, JSON.stringify(rawAccessToken), { + "Content-Type": "application/json", + }); + + const accessToken = + await base44.asServiceRole.sso.getAccessToken(userId); + + // Preserve the legacy public response type for compatibility while + // locking down the endpoint's existing raw-string runtime behavior. + expect(accessToken).toBe(rawAccessToken); + expect(scope.isDone()).toBe(true); + }); + + test("getIdToken uses the service-role client with on-behalf-of authentication", async () => { + const rawIdToken = "raw-id-token"; + + scope + .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) + .matchHeader("Authorization", `Bearer ${serviceToken}`) + .matchHeader("on-behalf-of", `Bearer ${userToken}`) + .reply(200, JSON.stringify(rawIdToken), { + "Content-Type": "application/json", + }); + + const idToken = await base44.asServiceRole.sso.getIdToken(userId); + + expect(idToken).toBe(rawIdToken); + expect(scope.isDone()).toBe(true); + }); + + test("getIdToken surfaces a 404 when no ID token is stored", async () => { + scope + .get(`/api/apps/${appId}/auth/sso/idtoken/${userId}`) + .reply(404, { detail: "No ID token stored", code: "NOT_FOUND" }); + + await expect( + base44.asServiceRole.sso.getIdToken(userId) + ).rejects.toMatchObject({ + name: "Base44Error", + status: 404, + code: "NOT_FOUND", + }); + + expect(scope.isDone()).toBe(true); + }); +});