Skip to content
Open
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
25 changes: 14 additions & 11 deletions src/app/utils/callback-url.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import appConfig from "../../config.js";

/**
* Build the OAuth authorize callback URL from the current request.
* Build the post-auth callback URL from the current request.
*
* If the request carries OAuth 2.1 authorize params (response_type, client_id,
* etc.), they are forwarded to the auth app. Otherwise a default authorize
* URL for the planner client is returned.
* etc.), the user came from the planner — forward them to the authorize
* endpoint so the OAuth flow completes.
*
* Otherwise the user came directly to the auth app — redirect to the auth
* app's own /profile page. The planner session can only be started from
* the planner (it holds the PKCE verifier + state), so there's no point
* trying to initiate an OAuth flow here.
*
* @param {import("hono").Context} c - Hono context
* @returns {string} Authorize URL
* @returns {string} Redirect URL
*/
export function getCallbackURL(c) {
const url = new URL(c.req.url);
const search = new URLSearchParams(url.search);
// Use the configured base URL rather than request origin — behind
// Heroku/Cloudflare TLS terminates at the edge, so origin would be
// http:// not https://, which Better Auth rejects in trustedOrigins.
const base = appConfig.base_url;

if (search.has("response_type")) {
Expand All @@ -31,11 +39,6 @@ export function getCallbackURL(c) {
return `${base}/api/auth/oauth2/authorize?${params.toString()}`;
}

const params = new URLSearchParams({
client_id: "planner",
redirect_uri: appConfig.allowed_redirects[0],
response_type: "code",
scope: "openid profile email",
});
return `${base}/api/auth/oauth2/authorize?${params.toString()}`;
// Direct visit — no OAuth flow to resume, send to profile
return `${base}/profile`;
}
47 changes: 11 additions & 36 deletions test/unit/callback-url.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { test } from "tap";
import { getCallbackURL } from "../../src/app/utils/callback-url.js";
import appConfig, { PLANNER_DEFAULT_PORT } from "../../src/config.js";
import appConfig, {
AUTH_DEFAULT_PORT,
PLANNER_DEFAULT_PORT,
} from "../../src/config.js";

const BASE = appConfig.base_url;
const REDIRECT = appConfig.allowed_redirects[0];
const BASE = `http://localhost:${AUTH_DEFAULT_PORT}`;
const REDIRECT_URI = `http://localhost:${PLANNER_DEFAULT_PORT}/auth/codebar/callback`;

function mockCtx(url) {
Expand Down Expand Up @@ -36,51 +38,24 @@ test("preserves only present OAuth params", async (t) => {
);
});

test("returns default authorize URL when no OAuth params", async (t) => {
test("returns /profile when no OAuth params", async (t) => {
const url = `${BASE}/login`;

const result = getCallbackURL(mockCtx(url));

const expected =
`${BASE}/api/auth/oauth2/authorize?` +
`client_id=planner&redirect_uri=${encodeURIComponent(REDIRECT)}` +
`&response_type=code&scope=openid+profile+email`;
t.equal(result, expected);
t.equal(result, `${BASE}/profile`);
});

test("returns default authorize URL with empty search", async (t) => {
test("returns /profile with empty search", async (t) => {
const url = `${BASE}/login?`;

const result = getCallbackURL(mockCtx(url));

const expected =
`${BASE}/api/auth/oauth2/authorize?` +
`client_id=planner&redirect_uri=${encodeURIComponent(REDIRECT)}` +
`&response_type=code&scope=openid+profile+email`;
t.equal(result, expected);
t.equal(result, `${BASE}/profile`);
});

test("uses base_url from config", async (t) => {
const original = appConfig.base_url;
appConfig.base_url = "https://auth.codebar.io";
t.after(() => {
appConfig.base_url = original;
});

const result = getCallbackURL(mockCtx("https://auth.codebar.io/login"));

t.ok(result.startsWith("https://auth.codebar.io/"));
t.ok(result.includes("/api/auth/oauth2/authorize"));
});

test("uses allowed_redirects[0] for default redirect_uri", async (t) => {
const original = appConfig.allowed_redirects;
appConfig.allowed_redirects = ["https://planner.test/callback"];
t.after(() => {
appConfig.allowed_redirects = original;
});

test("uses base_url for /profile redirect", async (t) => {
const result = getCallbackURL(mockCtx(`${BASE}/login`));

t.ok(result.includes(encodeURIComponent("https://planner.test/callback")));
t.equal(result, `${appConfig.base_url}/profile`);
});