mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge remote-tracking branch 'origin/dev' into cl/hexclave-pr3
# Conflicts: # apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx # apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx # apps/backend/src/lib/request-api-url.ts # apps/backend/src/oauth/index.tsx # apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx
This commit is contained in:
commit
56dd55d2ee
@ -140,7 +140,7 @@ export const GET = createSmartRouteHandler({
|
||||
// Hexclave rebrand: prefer the new query param name, accept the legacy one,
|
||||
// and only fall back to "redirect" when neither was provided.
|
||||
const responseMode = query.hexclave_response_mode ?? query.stack_response_mode ?? "redirect";
|
||||
const providerObj = await getProvider(provider, { apiUrl: getApiUrlForRequest(fullReq) });
|
||||
const providerObj = await getProvider(provider);
|
||||
const oauthUrl = providerObj.getAuthorizationUrl({
|
||||
codeVerifier: innerCodeVerifier,
|
||||
state: innerState,
|
||||
@ -169,6 +169,11 @@ export const GET = createSmartRouteHandler({
|
||||
turnstileResult: turnstileAssessment.status,
|
||||
turnstileVisibleChallengeResult: turnstileAssessment.visibleChallengeResult,
|
||||
responseMode,
|
||||
// Record the host that received /authorize so the callback can detect a
|
||||
// legitimate cross-host landing (the redirect_uri/callback host is
|
||||
// config-derived and may be a sibling brand) and not fail the
|
||||
// host-scoped CSRF cookie check.
|
||||
authorizeApiUrl: getApiUrlForRequest(fullReq),
|
||||
} satisfies InferType<typeof oauthCookieSchema>,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes),
|
||||
},
|
||||
|
||||
@ -115,9 +115,19 @@ const handler = createSmartRouteHandler({
|
||||
throw new HexclaveAssertionError("Invalid outer info");
|
||||
}
|
||||
|
||||
// The inner CSRF cookie is host-scoped to the host that served /authorize.
|
||||
// The OAuth redirect_uri (and thus this callback's host) is config-derived
|
||||
// and can be a sibling brand (e.g. authorize on api.hexclave.com, callback
|
||||
// on api.stack-auth.com for a legacy/shared provider). Cookies can't span
|
||||
// those hosts, so when the landing host legitimately differs from the
|
||||
// authorize host we skip the cookie check and rely on the single-use,
|
||||
// server-side outer info plus the outer flow's PKCE — the same protection
|
||||
// JSON mode already uses.
|
||||
const isCrossHostCallback = !!outerInfo.authorizeApiUrl && outerInfo.authorizeApiUrl !== apiUrl;
|
||||
|
||||
// JSON-mode requests use PKCE for CSRF protection and don't set a cookie.
|
||||
// Only check the CSRF cookie for browser-redirect mode requests.
|
||||
if (outerInfo.responseMode !== 'json') {
|
||||
// Only check the CSRF cookie for same-host browser-redirect mode requests.
|
||||
if (outerInfo.responseMode !== 'json' && !isCrossHostCallback) {
|
||||
// Hexclave rebrand: read whichever inner-OAuth cookie name is present (prefer the new name), and delete both.
|
||||
const cookieStore = await cookies();
|
||||
const cookieInfo = cookieStore.get("hexclave-oauth-inner-" + innerState)
|
||||
@ -164,7 +174,7 @@ const handler = createSmartRouteHandler({
|
||||
throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id));
|
||||
}
|
||||
|
||||
const providerObj = await getProvider(provider as any, { apiUrl });
|
||||
const providerObj = await getProvider(provider as any);
|
||||
let callbackResult: Awaited<ReturnType<typeof providerObj.getCallback>>;
|
||||
try {
|
||||
callbackResult = await providerObj.getCallback({
|
||||
|
||||
@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { connectedAccountAccessTokenCrud } from "@hexclave/shared/dist/interface/crud/connected-accounts";
|
||||
import { userIdOrMeSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { createLazyProxy } from "@hexclave/shared/dist/utils/proxies";
|
||||
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
|
||||
@ -42,11 +41,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
|
||||
throw new KnownErrors.OAuthConnectionNotConnectedToUser();
|
||||
}
|
||||
|
||||
// See sibling `[provider_id]/access-token/crud.tsx` — `redirect_uri` is
|
||||
// unused for refresh/check paths, so the apiUrl value passed here is safe
|
||||
// to pin to the deployment default even when the request came in on the
|
||||
// other branded host.
|
||||
const providerInstance = await getProvider(provider, { apiUrl: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") });
|
||||
const providerInstance = await getProvider(provider);
|
||||
const prisma = await getPrismaClientForTenancy(auth.tenancy);
|
||||
|
||||
const oauthAccount = await prisma.projectUserOAuthAccount.findFirst({
|
||||
|
||||
@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { connectedAccountAccessTokenCrud } from "@hexclave/shared/dist/interface/crud/connected-accounts";
|
||||
import { userIdOrMeSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { createLazyProxy } from "@hexclave/shared/dist/utils/proxies";
|
||||
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
|
||||
@ -39,10 +38,9 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
|
||||
|
||||
// The connected-accounts access-token flow only uses the OAuth provider's
|
||||
// refresh and access-token-validity methods; neither uses `redirect_uri`.
|
||||
// The CRUD handler interface does not surface the inbound request to this
|
||||
// callback, so we pass the deployment default API URL instead of the
|
||||
// request's host-derived one. Safe because the value is unused downstream.
|
||||
const providerInstance = await getProvider(provider, { apiUrl: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") });
|
||||
// `getProvider` resolves the callback URL from the provider's own config, so
|
||||
// this flow doesn't need to supply one.
|
||||
const providerInstance = await getProvider(provider);
|
||||
const prisma = await getPrismaClientForTenancy(auth.tenancy);
|
||||
|
||||
// Legacy endpoint: search tokens across ALL accounts for this provider and user
|
||||
|
||||
@ -92,6 +92,15 @@ function parseCoordinate(raw: string | null | undefined): number | undefined {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function decodeVercelGeoHeader(raw: string | null | undefined): string | undefined {
|
||||
if (raw == null || raw === "") return undefined;
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy):
|
||||
| { maybeSpoofed: true, spoofedInfo: EndUserInfoInner }
|
||||
| { maybeSpoofed: false, exactInfo: EndUserInfoInner }
|
||||
@ -133,7 +142,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy):
|
||||
const geoLocation: EndUserLocation = {
|
||||
countryCode: rawCountryCode ? normalizeCountryCode(rawCountryCode) : undefined,
|
||||
regionCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country-region") : undefined) || undefined,
|
||||
cityName: (isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined) || undefined,
|
||||
cityName: decodeVercelGeoHeader(isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined),
|
||||
latitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-latitude") : null),
|
||||
longitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-longitude") : null),
|
||||
tzIdentifier: (isVercelTrusted ? allHeaders.get("x-vercel-ip-timezone") : undefined) || undefined,
|
||||
@ -144,7 +153,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy):
|
||||
const spoofedGeoLocation: EndUserLocation = trustedProxy === "" ? {
|
||||
countryCode: rawSpoofedCountryCode ? normalizeCountryCode(rawSpoofedCountryCode) : undefined,
|
||||
regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined,
|
||||
cityName: allHeaders.get("x-vercel-ip-city") || undefined,
|
||||
cityName: decodeVercelGeoHeader(allHeaders.get("x-vercel-ip-city")),
|
||||
latitude: parseCoordinate(allHeaders.get("x-vercel-ip-latitude")),
|
||||
longitude: parseCoordinate(allHeaders.get("x-vercel-ip-longitude")),
|
||||
tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined,
|
||||
@ -315,4 +324,48 @@ import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("decodes URL-encoded city names from Vercel geo headers", () => {
|
||||
// Vercel percent-encodes city names, so a multi-word city arrives as "San%20Francisco".
|
||||
const result = getBrowserEndUserInfo(new Headers({
|
||||
"user-agent": "Mozilla/5.0",
|
||||
"x-vercel-forwarded-for": "203.0.113.10",
|
||||
"x-vercel-ip-country": "US",
|
||||
"x-vercel-ip-country-region": "CA",
|
||||
"x-vercel-ip-city": "San%20Francisco",
|
||||
"x-vercel-ip-latitude": "37.77",
|
||||
"x-vercel-ip-longitude": "-122.41",
|
||||
"x-vercel-ip-timezone": "America/Los_Angeles",
|
||||
}), "vercel");
|
||||
|
||||
expect(result).toEqual({
|
||||
maybeSpoofed: false,
|
||||
exactInfo: {
|
||||
ip: "203.0.113.10",
|
||||
countryCode: "US",
|
||||
regionCode: "CA",
|
||||
cityName: "San Francisco",
|
||||
latitude: 37.77,
|
||||
longitude: -122.41,
|
||||
tzIdentifier: "America/Los_Angeles",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the raw city name when it is not valid percent-encoding", () => {
|
||||
// A lone "%" is invalid percent-encoding; decoding must not throw, just pass it through.
|
||||
const result = getBrowserEndUserInfo(new Headers({
|
||||
"user-agent": "Mozilla/5.0",
|
||||
"x-vercel-forwarded-for": "203.0.113.10",
|
||||
"x-vercel-ip-city": "100% Real City",
|
||||
}), "vercel");
|
||||
|
||||
expect(result).toEqual({
|
||||
maybeSpoofed: false,
|
||||
exactInfo: {
|
||||
ip: "203.0.113.10",
|
||||
cityName: "100% Real City",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -235,6 +235,10 @@ export async function createOrUpdateProjectWithLegacyConfig(
|
||||
isShared: provider.type === "shared",
|
||||
clientId: provider.client_id,
|
||||
clientSecret: provider.client_secret,
|
||||
// Injecting the hexclave-branded callback for new providers is the
|
||||
// dashboard's job; this legacy path leaves it unset so providers fall
|
||||
// back to the stack-auth callback.
|
||||
customCallbackUrl: undefined,
|
||||
facebookConfigId: provider.facebook_config_id,
|
||||
microsoftTenantId: provider.microsoft_tenant_id,
|
||||
appleBundles: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(bundleId => [generateUuid(), { bundleId }] as const)) : undefined,
|
||||
|
||||
@ -1,25 +1,15 @@
|
||||
import { CLOUD_HOST_PAIRS } from "@hexclave/shared/dist/utils/cloud-hosts";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
|
||||
|
||||
/**
|
||||
* Single source of truth for the stack-auth ↔ hexclave host pairs that this
|
||||
* backend treats as equivalent siblings. Each `[stackAuthHost, hexclaveHost]`
|
||||
* pair is used in two places:
|
||||
*
|
||||
* 1. `CLOUD_API_HOST_BY_REQUEST_HOST` below — the allowlist of hosts we are
|
||||
* willing to resolve into a JWT `iss` claim or an OAuth `redirect_uri`.
|
||||
* 2. `issuerHostAliases` in `tokens.tsx` — the bidirectional validator alias
|
||||
* map, so a token issued under either host validates against the other.
|
||||
*
|
||||
* Deriving both lists from this single list prevents drift (a host can sign
|
||||
* but no sibling can validate, or vice versa) — that bug ate us once on the
|
||||
* staging pair before this consolidation.
|
||||
* The stack-auth ↔ hexclave cloud host pairs live in stack-shared
|
||||
* (`utils/cloud-hosts.ts`) so the dashboard and OAuth callback logic can share
|
||||
* them. Re-exported here because `tokens.tsx` imports it from this module to
|
||||
* build `issuerHostAliases` (and the source-of-truth comment lives with the
|
||||
* pairs themselves).
|
||||
*/
|
||||
export const CLOUD_HOST_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
["api.stack-auth.com", "api.hexclave.com"],
|
||||
["api.dev.stack-auth.com", "api.dev.hexclave.com"],
|
||||
["api.staging.stack-auth.com", "api.staging.hexclave.com"],
|
||||
];
|
||||
export { CLOUD_HOST_PAIRS };
|
||||
|
||||
/**
|
||||
* Cloud hosts where this backend serves customer SDK traffic. Each request
|
||||
|
||||
@ -52,6 +52,13 @@ export const oauthCookieSchema = yupObject({
|
||||
turnstileResult: yupString().oneOf(turnstileResultValues).optional(),
|
||||
turnstileVisibleChallengeResult: yupString().oneOf(turnstileResultValues).optional(),
|
||||
responseMode: yupString().oneOf(['json', 'redirect']).optional(),
|
||||
// The host-derived API URL of the request that started /authorize. The
|
||||
// browser-redirect CSRF cookie is host-scoped to that host, but the OAuth
|
||||
// `redirect_uri` (and thus the callback host) is now config-derived and can be
|
||||
// a sibling brand. The callback uses this to detect a legitimate cross-host
|
||||
// landing and skip the cookie check (server-side state + outer PKCE still
|
||||
// apply). Optional for in-flight flows started before this field existed.
|
||||
authorizeApiUrl: yupString().optional(),
|
||||
});
|
||||
|
||||
type UserType = 'normal' | 'restricted' | 'anonymous';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { DEFAULT_BRANCH_ID, Tenancy } from "@/lib/tenancies";
|
||||
import { DiscordProvider } from "@/oauth/providers/discord";
|
||||
import OAuth2Server from "@node-oauth/oauth2-server";
|
||||
import { getStackAuthApiBaseUrl } from "@hexclave/shared/dist/utils/cloud-hosts";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { OAuthModel } from "./model";
|
||||
@ -18,16 +19,6 @@ import { SpotifyProvider } from "./providers/spotify";
|
||||
import { TwitchProvider } from "./providers/twitch";
|
||||
import { XProvider } from "./providers/x";
|
||||
|
||||
type GetProviderOptions = {
|
||||
// Host-derived API URL — gets stamped into the OAuth provider's
|
||||
// `redirect_uri` (the URL sent to Google/GitHub/etc. and registered in their
|
||||
// app config). See `request-api-url.ts`. Pass `getApiUrlForRequest(fullReq)`
|
||||
// from a route handler. Customers whose providers were registered against
|
||||
// `api.stack-auth.com` will continue to have authorize calls send that exact
|
||||
// URL; customers on `api.hexclave.com` will see the hexclave-branded URL.
|
||||
apiUrl: string,
|
||||
};
|
||||
|
||||
const _providers = {
|
||||
github: GithubProvider,
|
||||
google: GoogleProvider,
|
||||
@ -66,12 +57,57 @@ export function getProjectBranchFromClientId(clientId: string): [projectId: stri
|
||||
return [projectId, branchId];
|
||||
}
|
||||
|
||||
// Resolves the OAuth `redirect_uri` we send to the provider (Google/GitHub/...)
|
||||
// and that the customer registers in their provider app config.
|
||||
//
|
||||
// - shared providers -> always the stack-auth-branded callback,
|
||||
// so Stack's shared OAuth apps keep working
|
||||
// - custom + `customCallbackUrl` -> the configured URL verbatim (new custom
|
||||
// providers get a hexclave-branded URL)
|
||||
// - custom without it (legacy) -> the stack-auth-branded callback, so
|
||||
// providers registered before this field
|
||||
// are unaffected
|
||||
//
|
||||
// `deploymentApiUrl` is this deployment's `NEXT_PUBLIC_STACK_API_URL`. The
|
||||
// stack-auth brand is derived from it (mapping cloud siblings), falling back to
|
||||
// it unchanged for self-hosted / localhost. This intentionally no longer depends
|
||||
// on the request host header.
|
||||
function getRedirectUri(
|
||||
provider: Tenancy['config']['auth']['oauth']['providers'][string],
|
||||
providerType: string,
|
||||
deploymentApiUrl: string,
|
||||
): string {
|
||||
if (!provider.isShared && provider.customCallbackUrl) {
|
||||
return provider.customCallbackUrl;
|
||||
}
|
||||
const stackAuthBaseUrl = getStackAuthApiBaseUrl(deploymentApiUrl);
|
||||
return `${stackAuthBaseUrl}/api/v1/auth/oauth/callback/${providerType}`;
|
||||
}
|
||||
|
||||
import.meta.vitest?.test("getRedirectUri keeps existing customers on the stack-auth callback", ({ expect }) => {
|
||||
const legacyCustom = { type: "github", isShared: false, customCallbackUrl: undefined } as any;
|
||||
const sharedProvider = { type: "github", isShared: true } as any;
|
||||
const newCustom = { type: "github", isShared: false, customCallbackUrl: "https://api.hexclave.com/api/v1/auth/oauth/callback/github" } as any;
|
||||
|
||||
// On a hexclave-branded deployment, existing customers (legacy custom + shared)
|
||||
// still get the stack-auth callback they registered — unchanged by the rebrand.
|
||||
expect(getRedirectUri(legacyCustom, "github", "https://api.hexclave.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
|
||||
expect(getRedirectUri(sharedProvider, "github", "https://api.hexclave.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
|
||||
// Only providers that explicitly set customCallbackUrl get the new brand.
|
||||
expect(getRedirectUri(newCustom, "github", "https://api.hexclave.com")).toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/github");
|
||||
|
||||
// On a stack-auth-branded deployment, unchanged too.
|
||||
expect(getRedirectUri(legacyCustom, "github", "https://api.stack-auth.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
|
||||
|
||||
// Self-host / localhost (not a cloud sibling): falls back to the deployment URL.
|
||||
expect(getRedirectUri(legacyCustom, "github", "http://localhost:8102")).toBe("http://localhost:8102/api/v1/auth/oauth/callback/github");
|
||||
});
|
||||
|
||||
export async function getProvider(
|
||||
provider: Tenancy['config']['auth']['oauth']['providers'][string],
|
||||
options: GetProviderOptions,
|
||||
): Promise<OAuthBaseProvider> {
|
||||
const { apiUrl } = options;
|
||||
const providerType = provider.type || throwErr("Provider type is required for shared providers");
|
||||
const redirectUri = getRedirectUri(provider, providerType, getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
|
||||
if (provider.isShared) {
|
||||
const clientId = _getEnvForProvider(providerType).clientId;
|
||||
const clientSecret = _getEnvForProvider(providerType).clientSecret;
|
||||
@ -79,12 +115,12 @@ export async function getProvider(
|
||||
if (clientSecret !== "MOCK") {
|
||||
throw new HexclaveAssertionError("If OAuth provider client ID is set to MOCK, then client secret must also be set to MOCK");
|
||||
}
|
||||
return await mockProvider.create(providerType, { apiUrl });
|
||||
return await mockProvider.create(providerType, { redirectUri });
|
||||
} else {
|
||||
return await _providers[providerType].create({
|
||||
clientId,
|
||||
clientSecret,
|
||||
apiUrl,
|
||||
redirectUri,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@ -93,7 +129,7 @@ export async function getProvider(
|
||||
clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"),
|
||||
facebookConfigId: provider.facebookConfigId,
|
||||
microsoftTenantId: provider.microsoftTenantId,
|
||||
apiUrl,
|
||||
redirectUri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,14 +10,14 @@ export class AppleProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new AppleProvider(
|
||||
...(await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://appleid.apple.com",
|
||||
authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
|
||||
tokenEndpoint: "https://appleid.apple.com/auth/token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/apple",
|
||||
redirectUri,
|
||||
jwksUri: "https://appleid.apple.com/auth/keys",
|
||||
baseScope: "name email",
|
||||
authorizationExtraParams: { "response_mode": "form_post" },
|
||||
|
||||
@ -8,14 +8,14 @@ export class BitbucketProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new BitbucketProvider(
|
||||
...(await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://bitbucket.org",
|
||||
authorizationEndpoint: "https://bitbucket.org/site/oauth2/authorize",
|
||||
tokenEndpoint: "https://bitbucket.org/site/oauth2/access_token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/bitbucket",
|
||||
redirectUri,
|
||||
baseScope: "account email",
|
||||
...rest,
|
||||
}))
|
||||
|
||||
@ -11,14 +11,14 @@ export class DiscordProvider extends OAuthBaseProvider {
|
||||
static async create(options: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new DiscordProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://discord.com",
|
||||
authorizationEndpoint: "https://discord.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://discord.com/api/oauth2/token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/discord",
|
||||
redirectUri,
|
||||
baseScope: "identify email",
|
||||
...rest,
|
||||
}));
|
||||
|
||||
@ -13,14 +13,14 @@ export class FacebookProvider extends OAuthBaseProvider {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
facebookConfigId?: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new FacebookProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://www.facebook.com",
|
||||
authorizationEndpoint: "https://facebook.com/v20.0/dialog/oauth/",
|
||||
tokenEndpoint: "https://graph.facebook.com/v20.0/oauth/access_token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/facebook",
|
||||
redirectUri,
|
||||
baseScope: "openid public_profile email",
|
||||
openid: true,
|
||||
jwksUri: "https://www.facebook.com/.well-known/oauth/openid/jwks",
|
||||
|
||||
@ -13,16 +13,16 @@ export class GithubProvider extends OAuthBaseProvider {
|
||||
static async create(options: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new GithubProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://github.com",
|
||||
alternativeIssuers: ["https://github.com/login/oauth"],
|
||||
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
||||
userinfoEndpoint: "https://api.github.com/user",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/github",
|
||||
redirectUri,
|
||||
baseScope: "user:email",
|
||||
// GitHub can return either non-expiring OAuth-App-style access tokens, or
|
||||
// expiring user tokens with refresh tokens. If GitHub gives us expires_in,
|
||||
|
||||
@ -8,15 +8,15 @@ export class GitlabProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new GitlabProvider(
|
||||
...(await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://gitlab.com",
|
||||
authorizationEndpoint: "https://gitlab.com/oauth/authorize",
|
||||
tokenEndpoint: "https://gitlab.com/oauth/token",
|
||||
userinfoEndpoint: "https://gitlab.com/api/v4/user",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/gitlab",
|
||||
redirectUri,
|
||||
baseScope: "read_user",
|
||||
...rest,
|
||||
}))
|
||||
|
||||
@ -11,15 +11,15 @@ export class GoogleProvider extends OAuthBaseProvider {
|
||||
static async create(options: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new GoogleProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://accounts.google.com",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/google",
|
||||
redirectUri,
|
||||
openid: true,
|
||||
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
||||
baseScope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
|
||||
|
||||
@ -10,14 +10,14 @@ export class LinkedInProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new LinkedInProvider(
|
||||
...(await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://www.linkedin.com/oauth",
|
||||
authorizationEndpoint: "https://www.linkedin.com/oauth/v2/authorization",
|
||||
tokenEndpoint: "https://www.linkedin.com/oauth/v2/accessToken",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/linkedin",
|
||||
redirectUri,
|
||||
baseScope: "openid profile email",
|
||||
openid: true,
|
||||
jwksUri: "https://www.linkedin.com/oauth/openid/jwks",
|
||||
|
||||
@ -12,17 +12,17 @@ export class MicrosoftProvider extends OAuthBaseProvider {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
microsoftTenantId?: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const tenantId = encodeURIComponent(options.microsoftTenantId || "consumers");
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new MicrosoftProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
// Note that it is intentional to have tenantid instead of tenantId, also intentional to not be a template literal. This will be replaced by the openid-client library.
|
||||
// The library only supports azure tenancy with the discovery endpoint but not the manual setup, so we patch it to enable the tenantid replacement.
|
||||
issuer: "https://login.microsoftonline.com/{tenantid}/v2.0",
|
||||
authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
|
||||
tokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/microsoft",
|
||||
redirectUri,
|
||||
baseScope: "User.Read openid",
|
||||
openid: true,
|
||||
jwksUri: `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,
|
||||
|
||||
@ -9,10 +9,10 @@ export class MockProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(providerId: string, options: { apiUrl: string }) {
|
||||
static async create(providerId: string, options: { redirectUri: string }) {
|
||||
return new MockProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
discoverFromUrl: getEnvVariable("STACK_OAUTH_MOCK_URL"),
|
||||
redirectUri: `${options.apiUrl}/api/v1/auth/oauth/callback/${providerId}`,
|
||||
redirectUri: options.redirectUri,
|
||||
baseScope: "openid offline_access",
|
||||
openid: true,
|
||||
clientId: providerId,
|
||||
|
||||
@ -11,14 +11,14 @@ export class SpotifyProvider extends OAuthBaseProvider {
|
||||
static async create(options: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new SpotifyProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://accounts.spotify.com",
|
||||
authorizationEndpoint: "https://accounts.spotify.com/authorize",
|
||||
tokenEndpoint: "https://accounts.spotify.com/api/token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/spotify",
|
||||
redirectUri,
|
||||
baseScope: "user-read-email user-read-private",
|
||||
...rest,
|
||||
}));
|
||||
|
||||
@ -11,15 +11,15 @@ export class TwitchProvider extends OAuthBaseProvider {
|
||||
static async create(options: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
apiUrl: string,
|
||||
redirectUri: string,
|
||||
}) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new TwitchProvider(...await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://id.twitch.tv",
|
||||
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
||||
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/twitch",
|
||||
redirectUri,
|
||||
baseScope: "user:read:email",
|
||||
...rest,
|
||||
}));
|
||||
|
||||
@ -9,14 +9,14 @@ export class XProvider extends OAuthBaseProvider {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
|
||||
const { apiUrl, ...rest } = options;
|
||||
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
|
||||
const { redirectUri, ...rest } = options;
|
||||
return new XProvider(
|
||||
...(await OAuthBaseProvider.createConstructorArgs({
|
||||
issuer: "https://twitter.com",
|
||||
authorizationEndpoint: "https://twitter.com/i/oauth2/authorize",
|
||||
tokenEndpoint: "https://api.x.com/2/oauth2/token",
|
||||
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/x",
|
||||
redirectUri,
|
||||
baseScope: "users.read offline.access tweet.read",
|
||||
...rest,
|
||||
}))
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
import { getHexclaveApiBaseUrl, getStackAuthApiBaseUrl } from "@hexclave/shared/dist/utils/cloud-hosts";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
|
||||
type ConfigOAuthProvider = CompleteConfig['auth']['oauth']['providers'][string];
|
||||
|
||||
function apiUrlEnv(): string {
|
||||
return getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL')
|
||||
?? throwErr("NEXT_PUBLIC_STACK_API_URL is required to build OAuth callback URLs");
|
||||
}
|
||||
|
||||
function callbackPath(providerId: string): string {
|
||||
return urlString`/api/v1/auth/oauth/callback/${providerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hexclave-branded callback URL written into `customCallbackUrl` when a new
|
||||
* custom OAuth provider is set up. Env-aware: maps this deployment's
|
||||
* `NEXT_PUBLIC_STACK_API_URL` to its hexclave sibling (self-host/localhost fall
|
||||
* back to the env var unchanged).
|
||||
*/
|
||||
export function getNewProviderCallbackUrl(providerId: string): string {
|
||||
return getHexclaveApiBaseUrl(apiUrlEnv()) + callbackPath(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack-auth-branded callback URL used by providers without a
|
||||
* `customCallbackUrl` (shared providers and custom providers created before the
|
||||
* field existed).
|
||||
*/
|
||||
export function getDefaultProviderCallbackUrl(providerId: string): string {
|
||||
return getStackAuthApiBaseUrl(apiUrlEnv()) + callbackPath(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URL to register with the provider, shown in the (standard-mode)
|
||||
* provider dialog. Mirrors what the standard write path persists and what the
|
||||
* backend then sends as `redirect_uri`:
|
||||
* - already standard -> its customCallbackUrl, or the stack-auth fallback for
|
||||
* legacy providers that never had one
|
||||
* - brand-new, or converting shared -> standard -> the new (hexclave) callback
|
||||
*/
|
||||
export function resolveProviderCallbackUrl(providerId: string, existing: ConfigOAuthProvider | undefined): string {
|
||||
if (existing && !existing.isShared) {
|
||||
return existing.customCallbackUrl ?? getDefaultProviderCallbackUrl(providerId);
|
||||
}
|
||||
return getNewProviderCallbackUrl(providerId);
|
||||
}
|
||||
@ -40,6 +40,7 @@ import { useId, useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
import { getNewProviderCallbackUrl } from "./oauth-callback-url";
|
||||
import { ProviderIcon, ProviderSettingDialog, ProviderSettingSwitch, TurnOffProviderDialog } from "./providers";
|
||||
|
||||
type AdminOAuthProviderConfig = AdminProject['config']['oauthProviders'][number];
|
||||
@ -102,7 +103,10 @@ function ConfirmSignUpDisabledDialog(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): CompleteConfig['auth']['oauth']['providers'][string] {
|
||||
function adminProviderToConfigProvider(
|
||||
provider: AdminOAuthProviderConfig,
|
||||
existing: CompleteConfig['auth']['oauth']['providers'][string] | undefined,
|
||||
): CompleteConfig['auth']['oauth']['providers'][string] {
|
||||
switch (provider.type) {
|
||||
case 'shared': {
|
||||
return {
|
||||
@ -110,6 +114,9 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp
|
||||
isShared: true,
|
||||
clientId: undefined,
|
||||
clientSecret: undefined,
|
||||
// Shared providers always use Stack's shared OAuth app; customCallbackUrl
|
||||
// is forbidden by the schema for them.
|
||||
customCallbackUrl: undefined,
|
||||
facebookConfigId: undefined,
|
||||
microsoftTenantId: undefined,
|
||||
appleBundles: undefined,
|
||||
@ -123,6 +130,13 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp
|
||||
isShared: false,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
// Setting up a standard provider (brand-new, or converting shared ->
|
||||
// standard) means registering a fresh OAuth app, so it gets the
|
||||
// hexclave-branded callback URL. A provider that was already standard
|
||||
// keeps whatever it had — legacy ones without a customCallbackUrl keep
|
||||
// falling back to the stack-auth callback so edits never silently change
|
||||
// an already-registered redirect URL.
|
||||
customCallbackUrl: (existing && !existing.isShared) ? existing.customCallbackUrl : getNewProviderCallbackUrl(provider.id),
|
||||
facebookConfigId: provider.facebookConfigId,
|
||||
microsoftTenantId: provider.microsoftTenantId,
|
||||
appleBundles: provider.appleBundleIds?.length
|
||||
@ -142,6 +156,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
|
||||
const stackAdminApp = useAdminApp();
|
||||
const project = stackAdminApp.useProject();
|
||||
const oauthProviders = project.config.oauthProviders;
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const filteredProviders = allProviders
|
||||
@ -175,7 +190,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
|
||||
await updateConfig({
|
||||
adminApp: stackAdminApp,
|
||||
configUpdate: {
|
||||
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider),
|
||||
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, config.auth.oauth.providers[provider.id]),
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
@ -204,6 +219,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
|
||||
|
||||
function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const completeConfig = stackAdminApp.useProject().useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const [turnOffProviderDialogOpen, setTurnOffProviderDialogOpen] = useState(false);
|
||||
const [providerSettingDialogOpen, setProviderSettingDialogOpen] = useState(false);
|
||||
@ -212,7 +228,7 @@ function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
|
||||
await updateConfig({
|
||||
adminApp: stackAdminApp,
|
||||
configUpdate: {
|
||||
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider),
|
||||
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, completeConfig.auth.oauth.providers[provider.id]),
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
DesignInput,
|
||||
DesignPillToggle,
|
||||
} from "@hexclave/dashboard-ui-components";
|
||||
import { getPublicEnvVar } from '@/lib/env';
|
||||
import { ArrowRightIcon, InfoIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { AdminProject } from "@hexclave/next";
|
||||
import { yupBoolean, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
@ -20,6 +19,8 @@ import { useState, type ReactNode } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
import { resolveProviderCallbackUrl } from "./oauth-callback-url";
|
||||
|
||||
export function ProviderIcon(props: { id: string, size?: "sm" | "md" | "lg" }) {
|
||||
const size = props.size ?? "md";
|
||||
@ -139,11 +140,13 @@ function PillToggleControl({
|
||||
}
|
||||
|
||||
function RedirectInline({ providerId }: { providerId: string }) {
|
||||
const config = useAdminApp().useProject().useConfig();
|
||||
const redirectUrl = resolveProviderCallbackUrl(providerId, config.auth.oauth.providers[providerId]);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Redirect URL</span>
|
||||
<Typography type="footnote" className="break-all">
|
||||
<InlineCode>{`${getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') ?? ''}${urlString`/api/v1/auth/oauth/callback/${providerId}`}`}</InlineCode>
|
||||
<InlineCode>{redirectUrl}</InlineCode>
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -115,6 +115,56 @@ it("should be able to fetch the inner callback URL by following the OAuth provid
|
||||
expect(innerCallbackUrl.pathname).toBe("/api/v1/auth/oauth/callback/spotify");
|
||||
});
|
||||
|
||||
it("sends the original (default) redirect_uri for a provider configured before customCallbackUrl existed, and the configured one when set", async ({ expect }) => {
|
||||
// Simulate an "old customer": a standard provider whose stored config predates
|
||||
// this feature and therefore has no customCallbackUrl. Alongside it, a provider
|
||||
// that opted into the new behavior with an explicit (different-brand) callback.
|
||||
await Project.createAndSwitch();
|
||||
await Project.updateConfig({
|
||||
"auth.oauth.providers.github": {
|
||||
type: "github",
|
||||
isShared: false,
|
||||
clientId: "legacy-client-id",
|
||||
clientSecret: "legacy-client-secret",
|
||||
allowSignIn: true,
|
||||
allowConnectedAccounts: true,
|
||||
},
|
||||
"auth.oauth.providers.spotify": {
|
||||
type: "spotify",
|
||||
isShared: false,
|
||||
clientId: "new-client-id",
|
||||
clientSecret: "new-client-secret",
|
||||
customCallbackUrl: "https://api.hexclave.com/api/v1/auth/oauth/callback/spotify",
|
||||
allowSignIn: true,
|
||||
allowConnectedAccounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The redirect_uri we hand the provider is carried as a query param on the
|
||||
// authorize redirect; read it without following the redirect to the provider.
|
||||
const redirectUriFor = async (provider: string) => {
|
||||
const response = await niceBackendFetch(`/api/v1/auth/oauth/authorize/${provider}`, {
|
||||
redirect: "manual",
|
||||
query: await Auth.OAuth.getAuthorizeQuery(),
|
||||
});
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
return new URL(location!).searchParams.get("redirect_uri");
|
||||
};
|
||||
|
||||
// Old customer: still gets the default deployment callback (locally the
|
||||
// localhost API URL) — exactly what they already registered with the provider,
|
||||
// and NOT the new hexclave-branded URL.
|
||||
const githubRedirectUri = await redirectUriFor("github");
|
||||
expect(new URL(githubRedirectUri!).origin).toBe(localhostUrl("02"));
|
||||
expect(new URL(githubRedirectUri!).pathname).toBe("/api/v1/auth/oauth/callback/github");
|
||||
expect(githubRedirectUri).not.toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/github");
|
||||
|
||||
// Provider that opted in: the configured customCallbackUrl is sent verbatim.
|
||||
expect(await redirectUriFor("spotify")).toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/spotify");
|
||||
});
|
||||
|
||||
it("should fail if an invalid client_id is provided", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
|
||||
redirect: "manual",
|
||||
|
||||
@ -426,6 +426,38 @@ describe("oauth config", () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("accepts customCallbackUrl on a standard oauth provider", async ({ expect }) => {
|
||||
const { adminAccessToken } = await Project.createAndSwitch();
|
||||
|
||||
const setResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
|
||||
method: "PATCH",
|
||||
accessType: "admin",
|
||||
headers: adminHeaders(adminAccessToken),
|
||||
body: {
|
||||
config_override_string: JSON.stringify({
|
||||
'auth.oauth.providers.google': {
|
||||
type: 'google',
|
||||
isShared: false,
|
||||
clientId: 'google-client-id',
|
||||
clientSecret: 'google-client-secret',
|
||||
customCallbackUrl: 'https://api.hexclave.com/api/v1/auth/oauth/callback/google',
|
||||
allowSignIn: true,
|
||||
allowConnectedAccounts: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(setResponse.status).toBe(200);
|
||||
|
||||
const configResponse = await niceBackendFetch("/api/v1/internal/config", {
|
||||
method: "GET",
|
||||
accessType: "admin",
|
||||
headers: adminHeaders(adminAccessToken),
|
||||
});
|
||||
const config = JSON.parse(configResponse.body.config_string);
|
||||
expect(config.auth.oauth.providers.google.customCallbackUrl).toBe('https://api.hexclave.com/api/v1/auth/oauth/callback/google');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -33,15 +33,7 @@ import { StackClientApp, StackProvider, useStackApp } from "@stackframe/stack";
|
||||
import { HexclaveClientApp, HexclaveProvider, useHexclaveApp } from "@hexclave/next";
|
||||
```
|
||||
|
||||
## 2. Update your OAuth callback URLs
|
||||
|
||||
After migrating, Hexclave sends providers (Google, GitHub, Microsoft, Apple, Facebook, Discord, LinkedIn, GitLab, Bitbucket, Spotify, Twitch, X) a `redirect_uri` on `api.hexclave.com`. Update each enabled provider's OAuth app callback URL to:
|
||||
|
||||
```
|
||||
https://api.hexclave.com/api/v1/auth/oauth/callback/<provider>
|
||||
```
|
||||
|
||||
## 3. Update hardcoded references
|
||||
## 2. Update hardcoded references
|
||||
|
||||
Sweep your codebase and replace:
|
||||
|
||||
@ -77,4 +69,10 @@ const { payload } = await jose.jwtVerify(token, jwks, {
|
||||
|
||||
The same applies to the anonymous (`/api/v1/projects-anonymous-users/...`) and restricted (`/api/v1/projects-restricted-users/...`) issuer variants.
|
||||
|
||||
You don't need to update your OAuth provider callback URLs — your current `api.stack-auth.com` callback URLs keep working. However, if you recreate an OAuth provider on the dashboard, you'll need to use the new callback URL:
|
||||
|
||||
```
|
||||
https://api.hexclave.com/api/v1/auth/oauth/callback/<provider>
|
||||
```
|
||||
|
||||
Questions? [Discord](https://discord.hexclave.com) or [team@hexclave.com](mailto:team@hexclave.com).
|
||||
|
||||
@ -209,6 +209,10 @@ const environmentSchemaFuzzerConfig = [{
|
||||
isShared: [true, false],
|
||||
clientId: ["some-client-id"],
|
||||
clientSecret: ["some-client-secret"],
|
||||
// Kept undefined: the schema forbids customCallbackUrl when isShared is
|
||||
// true, and the fuzzer randomizes each field independently so it cannot
|
||||
// honor that coupling. The accept path is covered by an e2e test.
|
||||
customCallbackUrl: [undefined] as (string | undefined)[],
|
||||
facebookConfigId: ["some-facebook-config-id"],
|
||||
microsoftTenantId: ["some-microsoft-tenant-id"],
|
||||
appleBundles: [{ "some-bundle-id": [{ bundleId: ["com.example.app"] }] }],
|
||||
|
||||
@ -382,6 +382,11 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
|
||||
isShared: yupBoolean(),
|
||||
clientId: schemaFields.oauthClientIdSchema.optional(),
|
||||
clientSecret: schemaFields.oauthClientSecretSchema.optional(),
|
||||
customCallbackUrl: schemaFields.oauthCustomCallbackUrlSchema.optional().when('isShared', {
|
||||
is: true,
|
||||
then: (schema) => schema.oneOf([undefined], 'customCallbackUrl cannot be set for shared OAuth providers'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(),
|
||||
microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
|
||||
appleBundles: yupRecord(
|
||||
@ -758,6 +763,7 @@ const organizationConfigDefaults = {
|
||||
allowConnectedAccounts: false,
|
||||
clientId: undefined,
|
||||
clientSecret: undefined,
|
||||
customCallbackUrl: undefined,
|
||||
facebookConfigId: undefined,
|
||||
microsoftTenantId: undefined,
|
||||
appleBundles: undefined,
|
||||
@ -1128,7 +1134,6 @@ export async function sanitizeOrganizationConfig(config: OrganizationRenderedCon
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Does not require a base config, and hence solely relies on the override itself to validate the config. If it returns
|
||||
* no error, you know that the
|
||||
|
||||
@ -592,6 +592,7 @@ export const oauthEnabledSchema = yupBoolean().meta({ openapiField: { descriptio
|
||||
export const oauthTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'OAuth provider type, one of shared, standard. "shared" uses Stack shared OAuth keys and it is only meant for development. "standard" uses your own OAuth keys and will show your logo and company name when signing in with the provider.', exampleValue: 'standard' } });
|
||||
export const oauthClientIdSchema = yupString().meta({ openapiField: { description: 'OAuth client ID. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-id' } });
|
||||
export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } });
|
||||
export const oauthCustomCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The OAuth redirect/callback URL sent to the provider. When omitted, the default callback URL is used. Cannot be set for shared providers.', exampleValue: 'https://api.hexclave.com/api/v1/auth/oauth/callback/google' } });
|
||||
export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } });
|
||||
export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } });
|
||||
export const oauthAppleBundleIdsSchema = yupArray(yupString().defined()).meta({ openapiField: { description: 'Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).', exampleValue: ['com.example.ios', 'com.example.macos'] } });
|
||||
|
||||
92
packages/stack-shared/src/utils/cloud-hosts.tsx
Normal file
92
packages/stack-shared/src/utils/cloud-hosts.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Single source of truth for the stack-auth ↔ hexclave host pairs that the
|
||||
* cloud deployment treats as equivalent siblings. Each
|
||||
* `[stackAuthHost, hexclaveHost]` pair is consumed in a few places:
|
||||
*
|
||||
* 1. `CLOUD_API_HOST_BY_REQUEST_HOST` in the backend's `request-api-url.ts` —
|
||||
* the allowlist of request hosts we resolve into a JWT `iss` claim.
|
||||
* 2. `issuerHostAliases` in `tokens.tsx` — the bidirectional validator alias
|
||||
* map, so a token issued under either host validates against the other.
|
||||
* 3. `getCloudApiUrlSiblings` below — resolves the branded base URLs used for
|
||||
* OAuth `redirect_uri` callbacks (both at runtime and in the dashboard).
|
||||
*
|
||||
* Deriving all of these from this one list prevents drift.
|
||||
*/
|
||||
export const CLOUD_HOST_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
["api.stack-auth.com", "api.hexclave.com"],
|
||||
["api.dev.stack-auth.com", "api.dev.hexclave.com"],
|
||||
["api.staging.stack-auth.com", "api.staging.hexclave.com"],
|
||||
];
|
||||
|
||||
function hostFromApiUrlOrHost(input: string): string | undefined {
|
||||
try {
|
||||
return new URL(input).host.split(":")[0].toLowerCase();
|
||||
} catch {
|
||||
const firstHost = input.split(",")[0]?.trim();
|
||||
if (!firstHost) return undefined;
|
||||
return firstHost.split(":")[0].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an API URL (or bare host), if its host belongs to a known
|
||||
* stack-auth ↔ hexclave cloud pair, return both branded base URLs. Returns
|
||||
* null for unknown hosts (localhost, vercel previews, self-host custom
|
||||
* domains).
|
||||
*/
|
||||
export function getCloudApiUrlSiblings(apiUrlOrHost: string | undefined | null): { stackAuth: string, hexclave: string } | null {
|
||||
if (!apiUrlOrHost) return null;
|
||||
const host = hostFromApiUrlOrHost(apiUrlOrHost);
|
||||
if (!host) return null;
|
||||
for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) {
|
||||
if (host === stackAuthHost || host === hexclaveHost) {
|
||||
return { stackAuth: `https://${stackAuthHost}`, hexclave: `https://${hexclaveHost}` };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack-auth-branded base URL for the given deployment API URL. Used as the
|
||||
* OAuth `redirect_uri` base for shared providers and for custom providers that
|
||||
* predate `customCallbackUrl` (so existing flows keep hitting
|
||||
* `api.stack-auth.com`). Unknown hosts fall back to the input unchanged.
|
||||
*/
|
||||
export function getStackAuthApiBaseUrl(apiUrl: string): string {
|
||||
return getCloudApiUrlSiblings(apiUrl)?.stackAuth ?? apiUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hexclave-branded base URL for the given deployment API URL. Used when a
|
||||
* new custom OAuth provider is set up, so its `customCallbackUrl` points at the
|
||||
* hexclave brand. Unknown hosts fall back to the input unchanged.
|
||||
*/
|
||||
export function getHexclaveApiBaseUrl(apiUrl: string): string {
|
||||
return getCloudApiUrlSiblings(apiUrl)?.hexclave ?? apiUrl;
|
||||
}
|
||||
|
||||
import.meta.vitest?.test("getCloudApiUrlSiblings maps both sides of each cloud pair", ({ expect }) => {
|
||||
for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) {
|
||||
const expected = { stackAuth: `https://${stackAuthHost}`, hexclave: `https://${hexclaveHost}` };
|
||||
for (const host of [stackAuthHost, hexclaveHost]) {
|
||||
expect(getCloudApiUrlSiblings(host)).toEqual(expected);
|
||||
expect(getCloudApiUrlSiblings(`https://${host}`)).toEqual(expected);
|
||||
expect(getCloudApiUrlSiblings(`https://${host.toUpperCase()}:443`)).toEqual(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("getCloudApiUrlSiblings returns null for unknown hosts", ({ expect }) => {
|
||||
expect(getCloudApiUrlSiblings("http://localhost:8102")).toBeNull();
|
||||
expect(getCloudApiUrlSiblings("https://my-app.vercel.app")).toBeNull();
|
||||
expect(getCloudApiUrlSiblings(undefined)).toBeNull();
|
||||
expect(getCloudApiUrlSiblings("")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("getStackAuthApiBaseUrl / getHexclaveApiBaseUrl resolve brands and fall back", ({ expect }) => {
|
||||
expect(getStackAuthApiBaseUrl("https://api.hexclave.com")).toBe("https://api.stack-auth.com");
|
||||
expect(getHexclaveApiBaseUrl("https://api.stack-auth.com")).toBe("https://api.hexclave.com");
|
||||
expect(getStackAuthApiBaseUrl("https://api.dev.stack-auth.com")).toBe("https://api.dev.stack-auth.com");
|
||||
expect(getHexclaveApiBaseUrl("http://localhost:8102")).toBe("http://localhost:8102");
|
||||
expect(getStackAuthApiBaseUrl("http://localhost:8102")).toBe("http://localhost:8102");
|
||||
});
|
||||
@ -1,26 +1,10 @@
|
||||
# Hexclave: Open-source Clerk/Auth0 alternative
|
||||
# This package has been renamed.
|
||||
|
||||
## [📘 Docs](https://docs.hexclave.com) | [☁️ Hosted Version](https://hexclave.com/) | [✨ Demo](https://demo.hexclave.com/) | [🎮 Discord](https://discord.hexclave.com) | [GitHub](https://github.com/hexclave/hexclave)
|
||||
Stack Auth is now Hexclave! The new packages are:
|
||||
|
||||
Hexclave is a managed user authentication solution. It is developer-friendly and fully open-source (licensed under MIT and AGPL).
|
||||
- @hexclave/next
|
||||
- @hexclave/react
|
||||
- @hexclave/js
|
||||
- @hexclave/cli
|
||||
|
||||
Hexclave gets you started in just five minutes, after which you'll be ready to use all of its features as you grow your project. Our managed service is completely optional and you can export your user data and self-host, for free, at any time.
|
||||
|
||||
We support Next.js frontends, along with any backend that can use our [REST API](https://docs.hexclave.com/rest-api/overview). Check out our [setup guide](https://docs.hexclave.com/getting-started/setup) to get started.
|
||||
|
||||
## 📦 Installation & Setup
|
||||
|
||||
1. Run Hexclave's installation wizard with the following command:
|
||||
```bash
|
||||
npx @hexclave/cli@latest init
|
||||
```
|
||||
2. Then, create an account on the [Hexclave dashboard](https://app.hexclave.com/projects), create a new project with an API key, and copy its environment variables into the .env.local file of your Next.js project:
|
||||
```
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=<your-project-id>
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=<your-publishable-client-key>
|
||||
STACK_SECRET_SERVER_KEY=<your-secret-server-key>
|
||||
```
|
||||
3. That's it! You can run your app with `npm run dev` and go to [http://localhost:3000/handler/signup](http://localhost:3000/handler/signup) to see the sign-up page. You can also check out the account settings page at [http://localhost:3000/handler/account-settings](http://localhost:3000/handler/account-settings).
|
||||
|
||||
|
||||
Check out the [documentation](https://docs.hexclave.com/getting-started/setup) for a more detailed guide.
|
||||
See the [migration guide](https://docs.hexclave.com/migration) for more information.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user