Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/modules/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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);
},
};
}
33 changes: 28 additions & 5 deletions src/modules/sso.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { AxiosResponse } from "axios";

/**
* Response from SSO access token endpoint.
* @internal
Expand All @@ -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.
*
Expand Down Expand Up @@ -44,4 +42,29 @@ export interface SsoModule {
* ```
*/
getAccessToken(userid: string): Promise<SsoAccessTokenResponse>;

/**
* 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<string>;
}
94 changes: 94 additions & 0 deletions tests/unit/sso.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient>;
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);
});
});
Loading