mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Neon OAuth "Create new project" flow
This commit is contained in:
parent
0dcc9e0201
commit
b8b4ab98c1
@ -40,7 +40,7 @@ const handler = createSmartRouteHandler({
|
||||
body: yupMixed().optional(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([302]).defined(),
|
||||
statusCode: yupNumber().oneOf([307]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupMixed().defined(),
|
||||
headers: yupMixed().defined(),
|
||||
|
||||
@ -9,7 +9,11 @@ export function oauthResponseToSmartResponse(oauthResponse: OAuthResponse): Smar
|
||||
throw new StackAssertionError(`OAuth server error: ${JSON.stringify(oauthResponse.body)}`, { oauthResponse });
|
||||
} else if (oauthResponse.status >= 200 && oauthResponse.status < 500) {
|
||||
return {
|
||||
statusCode: oauthResponse.status,
|
||||
statusCode: {
|
||||
// our API never returns 301 or 302 by convention, so transform them to 307 or 308
|
||||
301: 308,
|
||||
302: 307,
|
||||
}[oauthResponse.status] ?? oauthResponse.status,
|
||||
bodyType: "json",
|
||||
body: oauthResponse.body,
|
||||
headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))),
|
||||
|
||||
@ -32,7 +32,7 @@ export const POST = createSmartRouteHandler({
|
||||
const set = await prismaClient.apiKeySet.create({
|
||||
data: {
|
||||
projectId: req.body.project_id,
|
||||
description: "API key for Neon x Stack Auth integration",
|
||||
description: "Auto-generated for Neon",
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100),
|
||||
superSecretAdminKey: `sak_${generateSecureRandomString()}`,
|
||||
},
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { prismaClient } from '@/prisma-client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { decodeBase64OrBase64Url } from '@stackframe/stack-shared/dist/utils/bytes';
|
||||
import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
|
||||
import { StackAssertionError, captureError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
|
||||
import { sha512 } from '@stackframe/stack-shared/dist/utils/hashes';
|
||||
import { getPerAudienceSecret, getPrivateJwk, getPublicJwkSet } from '@stackframe/stack-shared/dist/utils/jwt';
|
||||
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
|
||||
import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids';
|
||||
import Provider, { Adapter, AdapterConstructor, AdapterPayload } from 'oidc-provider';
|
||||
|
||||
@ -45,17 +47,20 @@ function createAdapter(options: {
|
||||
|
||||
constructor(model: string) {
|
||||
this.model = model;
|
||||
if (!model) {
|
||||
throw new StackAssertionError(deindent`
|
||||
model must be non-empty.
|
||||
|
||||
oidc-provider should never call the constructor with an empty string. However, it relies on 'constructor.name' in some locations, causing it to fail when class name minification is enabled. Make sure that server-side class names are not minified, for example by disabling serverMinification in next.config.mjs.
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload, expiresInSeconds: number): Promise<void> {
|
||||
try {
|
||||
if (expiresInSeconds < 0) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be non-negative, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
if (expiresInSeconds > 60 * 60 * 24 * 365 * 100) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be less than 100 years, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
if (!Number.isFinite(expiresInSeconds)) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be a finite number, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
} catch (err) {
|
||||
captureError('idp-adapter-upsert-assertion-error', err);
|
||||
expiresInSeconds = 60 * 60 * 60 * 24;
|
||||
}
|
||||
// if one of these assertions is triggered, make sure you're not minifying class names (see the constructor)
|
||||
if (expiresInSeconds < 0) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be non-negative, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
if (expiresInSeconds > 60 * 60 * 24 * 365 * 100) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be less than 100 years, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
if (!Number.isFinite(expiresInSeconds)) throw new StackAssertionError(`expiresInSeconds of ${this.model}:${id} must be a finite number, got ${expiresInSeconds}`, { expiresInSeconds, model: this.model, id, payload });
|
||||
|
||||
await niceUpdate(this.model, id, () => ({ payload, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) }));
|
||||
}
|
||||
@ -236,6 +241,16 @@ export async function createOidcProvider(options: { id: string, baseUrl: string
|
||||
});
|
||||
}
|
||||
|
||||
// Log all errors
|
||||
middleware(async (ctx, next) => {
|
||||
try {
|
||||
return await next();
|
||||
} catch (e) {
|
||||
console.warn("IdP threw an error. This most likely indicates a misconfigured client, not a server error.", e, { path: ctx.path, ctx });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// .well-known/jwks.json
|
||||
middleware(async (ctx, next) => {
|
||||
if (ctx.path === '/.well-known/jwks.json') {
|
||||
@ -339,9 +354,33 @@ export async function createOidcProvider(options: { id: string, baseUrl: string
|
||||
}
|
||||
}
|
||||
} else if (ctx.method === 'GET' && /^\/interaction\/[^/]+$/.test(ctx.path)) {
|
||||
const details = await oidc.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
const state = details.params.state || "";
|
||||
if (typeof state !== 'string') {
|
||||
throwErr(`state is not a string`);
|
||||
}
|
||||
let neonProjectDisplayName: string | undefined;
|
||||
try {
|
||||
const base64Decoded = new TextDecoder().decode(decodeBase64OrBase64Url(state));
|
||||
const json = JSON.parse(base64Decoded);
|
||||
neonProjectDisplayName = json?.details?.neon_project_name;
|
||||
if (typeof neonProjectDisplayName !== 'string') {
|
||||
throwErr(`neon_project_name is not a string`, { type: typeof neonProjectDisplayName, neonProjectDisplayName });
|
||||
}
|
||||
} catch (e) {
|
||||
// this probably shouldn't happen, because it means Neon messed up the configuration
|
||||
// (or maybe someone is playing with the API, but in that case it's not a bad idea to notify us either)
|
||||
// either way, let's capture an error and continue without the display name
|
||||
captureError('idp-oidc-provider-interaction-state-decode-error', e);
|
||||
}
|
||||
|
||||
const uid = ctx.path.split('/')[2];
|
||||
const interactionUrl = new URL(`/integrations/neon/confirm`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"));
|
||||
interactionUrl.searchParams.set("interaction_uid", uid);
|
||||
if (neonProjectDisplayName) {
|
||||
interactionUrl.searchParams.set("neon_project_display_name", neonProjectDisplayName);
|
||||
}
|
||||
return ctx.redirect(interactionUrl.toString());
|
||||
}
|
||||
await next();
|
||||
|
||||
@ -47,7 +47,11 @@ const handler = handleApiRequest(async (req: NextRequest) => {
|
||||
|
||||
return new NextResponse(body, {
|
||||
headers: Object.entries(serverResponse.getHeaders()).filter(([k, v]) => v) as any,
|
||||
status: serverResponse.statusCode,
|
||||
status: {
|
||||
// our API never returns 301 or 302 by convention, so transform them to 307 or 308
|
||||
301: 308,
|
||||
302: 307,
|
||||
}[serverResponse.statusCode] ?? serverResponse.statusCode,
|
||||
statusText: serverResponse.statusMessage,
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,8 +3,8 @@ import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'
|
||||
import { wait } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import './polyfills';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const corsAllowedRequestHeaders = [
|
||||
// General
|
||||
@ -43,7 +43,7 @@ export async function middleware(request: NextRequest) {
|
||||
const delay = +getEnvVariable('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS', '0');
|
||||
if (delay) {
|
||||
if (getNodeEnvironment().includes('production')) {
|
||||
throw new StackAssertionError('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS is only allowed in development');
|
||||
throw new StackAssertionError('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS environment variable is only allowed in development');
|
||||
}
|
||||
if (!request.headers.get('x-stack-disable-artificial-development-delay')) {
|
||||
await wait(delay);
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import "../polyfills";
|
||||
|
||||
import { yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as yup from "yup";
|
||||
import { createSmartRouteHandler } from "./smart-route-handler";
|
||||
import { yupObject, yupString, yupNumber, yupBoolean, yupArray, yupMixed } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
|
||||
export function redirectHandler(redirectPath: string, statusCode: 301 | 302 | 303 | 307 | 308 = 307): (req: NextRequest, options: any) => Promise<Response> {
|
||||
export function redirectHandler(redirectPath: string, statusCode: 303 | 307 | 308 = 307): (req: NextRequest, options: any) => Promise<Response> {
|
||||
return createSmartRouteHandler({
|
||||
request: yupObject({
|
||||
url: yupString().defined(),
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import "../polyfills";
|
||||
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { Json } from "@stackframe/stack-shared/dist/utils/json";
|
||||
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as yup from "yup";
|
||||
import { Json } from "@stackframe/stack-shared/dist/utils/json";
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { yupObject } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
|
||||
export type SmartResponse = {
|
||||
statusCode: number,
|
||||
|
||||
@ -82,6 +82,9 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque
|
||||
const timeStart = performance.now();
|
||||
const res = await handler(req, options, requestId);
|
||||
const time = (performance.now() - timeStart);
|
||||
if ([301, 302].includes(res.status)) {
|
||||
throw new StackAssertionError("HTTP status codes 301 and 302 should not be returned by our APIs because the behavior for non-GET methods is inconsistent across implementations. Use 303 (to rewrite method to GET) or 307/308 (to preserve the original method and data) instead.", { status: res.status, url: req.nextUrl, req, res });
|
||||
}
|
||||
console.log(`[ RES] [${requestId}] ${req.method} ${censoredUrl} (in ${time.toFixed(0)}ms)`);
|
||||
return res;
|
||||
} catch (e) {
|
||||
|
||||
@ -6,6 +6,7 @@ import { AuthPage, useUser } from "@stackframe/stack";
|
||||
import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
|
||||
import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { BrowserFrame, Button, Form, Separator, Typography } from "@stackframe/stack-ui";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
@ -33,6 +34,7 @@ export default function PageClient () {
|
||||
mode: "onChange",
|
||||
});
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const mockProject = {
|
||||
id: "id",
|
||||
@ -63,7 +65,14 @@ export default function PageClient () {
|
||||
} as const)).filter(({ enabled }) => enabled),
|
||||
}
|
||||
});
|
||||
router.push('/projects/' + newProject.id);
|
||||
const redirectToNeonConfirmWith = searchParams.get("redirect_to_neon_confirm_with");
|
||||
if (redirectToNeonConfirmWith) {
|
||||
const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith);
|
||||
confirmSearchParams.set("default_selected_project_id", newProject.id);
|
||||
router.push('/integrations/neon/confirm?' + confirmSearchParams.toString());
|
||||
} else {
|
||||
router.push('/projects/' + encodeURIComponent(newProject.id));
|
||||
}
|
||||
await wait(2000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@ -11,12 +11,12 @@ import NeonLogo from "../../../../../../public/neon.png";
|
||||
export default function NeonConfirmCard(props: { onContinue: (options: { projectId: string }) => Promise<{ error: string } | undefined> }) {
|
||||
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
|
||||
const projects = user.useOwnedProjects();
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<AdminProject | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<AdminProject | null>(projects.find((project) => project.id === searchParams.get("default_selected_project_id")) ?? null);
|
||||
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex-row items-end justify-center gap-4">
|
||||
@ -62,12 +62,20 @@ export default function NeonConfirmCard(props: { onContinue: (options: { project
|
||||
<Typography className="mb-3">
|
||||
Which projects would you like to connect?
|
||||
</Typography>
|
||||
<Input type="text" disabled prefixItem={<Image src={NeonLogo} alt="Neon" width={15} />} value={searchParams.get("neon_project_name") || "Neon project connected!"} />
|
||||
<Input type="text" disabled prefixItem={<Image src={NeonLogo} alt="Neon" width={15} />} value={searchParams.get("neon_project_display_name") || "Neon project connected!"} />
|
||||
<div className="flex flex-row items-center">
|
||||
<div className={'flex self-stretch justify-center items-center text-muted-foreground pl-3 select-none bg-muted/70 pr-3 border-r border-input rounded-l-md'}>
|
||||
<Logo noLink width={15} height={15} />
|
||||
</div>
|
||||
<Select value={selectedProject?.id ?? ""} onValueChange={(p) => setSelectedProject(projects.find((project) => project.id === p) ?? null)}>
|
||||
<Select value={selectedProject?.id ?? ""} onValueChange={(p) => {
|
||||
if (p === "create-new") {
|
||||
const createSearchParams = new URLSearchParams();
|
||||
createSearchParams.set("redirect_to_neon_confirm_with", searchParams.toString());
|
||||
window.location.href = '/new-project?' + createSearchParams.toString();
|
||||
} else {
|
||||
setSelectedProject(projects.find((project) => project.id === p) ?? null);
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeft: "none", }}>
|
||||
<SelectValue>
|
||||
{selectedProject && (
|
||||
@ -84,6 +92,9 @@ export default function NeonConfirmCard(props: { onContinue: (options: { project
|
||||
{project.displayName} <span className="text-xs text-muted-foreground">{(project.id)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="create-new">
|
||||
Create new Stack project...
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@ -144,7 +144,7 @@ export namespace Auth {
|
||||
if (response.status !== 200) {
|
||||
throw new StackAssertionError("Expected session to be valid, but was actually invalid.", { response });
|
||||
}
|
||||
expect(response).toEqual({
|
||||
expect(response).toMatchObject({
|
||||
status: 200,
|
||||
headers: expect.objectContaining({}),
|
||||
body: expect.objectContaining({}),
|
||||
@ -535,7 +535,7 @@ export namespace Auth {
|
||||
const redirectResponse1 = await niceFetch(authLocation, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(redirectResponse1).toEqual({
|
||||
expect(redirectResponse1).toMatchObject({
|
||||
status: 303,
|
||||
headers: expect.any(Headers),
|
||||
body: expect.any(String),
|
||||
@ -555,7 +555,7 @@ export namespace Auth {
|
||||
cookie: signInInteractionCookies,
|
||||
},
|
||||
});
|
||||
expect(response1).toEqual({
|
||||
expect(response1).toMatchObject({
|
||||
status: 303,
|
||||
headers: expect.any(Headers),
|
||||
body: expect.any(ArrayBuffer),
|
||||
@ -566,7 +566,7 @@ export namespace Auth {
|
||||
cookie: updateCookiesFromResponse(signInInteractionCookies, response1),
|
||||
},
|
||||
});
|
||||
expect(redirectResponse2).toEqual({
|
||||
expect(redirectResponse2).toMatchObject({
|
||||
status: 303,
|
||||
headers: expect.any(Headers),
|
||||
body: expect.any(String),
|
||||
@ -584,7 +584,7 @@ export namespace Auth {
|
||||
cookie: authorizeInteractionCookies,
|
||||
},
|
||||
});
|
||||
expect(response2).toEqual({
|
||||
expect(response2).toMatchObject({
|
||||
status: 303,
|
||||
headers: expect.any(Headers),
|
||||
body: expect.any(ArrayBuffer),
|
||||
@ -595,7 +595,7 @@ export namespace Auth {
|
||||
cookie: updateCookiesFromResponse(authorizeInteractionCookies, response2),
|
||||
},
|
||||
});
|
||||
expect(redirectResponse3).toEqual({
|
||||
expect(redirectResponse3).toMatchObject({
|
||||
status: 303,
|
||||
headers: expect.any(Headers),
|
||||
body: expect.any(String),
|
||||
@ -618,8 +618,8 @@ export namespace Auth {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({
|
||||
status: 302,
|
||||
expect(response).toMatchObject({
|
||||
status: 307,
|
||||
headers: expect.any(Headers),
|
||||
body: {},
|
||||
});
|
||||
@ -820,6 +820,14 @@ export namespace ApiKey {
|
||||
backendContext.set({ projectKeys: res.projectKeys });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function listAll() {
|
||||
const response = await niceBackendFetch("/api/v1/internal/api-keys", {
|
||||
accessType: "admin",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return response.body;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Project {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { ApiKey, Auth, Project, backendContext, niceBackendFetch } from "../../../../backend-helpers";
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { ApiKey, Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
|
||||
describe("without project access", () => {
|
||||
@ -27,6 +27,29 @@ describe("without project access", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("with server access", () => {
|
||||
it("should not have access to api keys", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/integrations/neon/api-keys", { accessType: "server" });
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": {
|
||||
"code": "INSUFFICIENT_ACCESS_TYPE",
|
||||
"details": {
|
||||
"actual_access_type": "server",
|
||||
"allowed_access_types": ["admin"],
|
||||
},
|
||||
"error": "The x-stack-access-type header must be 'admin', but was 'server'.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with admin access to the internal project", () => {
|
||||
it("list api keys", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
@ -164,9 +187,9 @@ describe("with admin access to a non-internal project", () => {
|
||||
"description": "new description",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -198,16 +221,16 @@ describe("with admin access to a non-internal project", () => {
|
||||
"description": "key2",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
{
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"description": "new description",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -235,9 +258,9 @@ describe("with admin access to a non-internal project", () => {
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"manually_revoked_at_millis": <stripped field 'manually_revoked_at_millis'>,
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Auth, Project, niceBackendFetch } from "../../../../backend-helpers";
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { Auth, Project, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
|
||||
it("creates a new oauth provider", async ({ expect }) => {
|
||||
@ -0,0 +1,268 @@
|
||||
import { encodeBase64Url } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { expect } from "vitest";
|
||||
import { it, updateCookiesFromResponse } from "../../../../../../helpers";
|
||||
import { ApiKey, Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
async function authorizePart1() {
|
||||
let cookies = "";
|
||||
const first = await niceBackendFetch("/api/v1/integrations/neon/oauth/authorize", {
|
||||
method: "GET",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: "neon-local",
|
||||
redirect_uri: "http://localhost:30000/api/v2/identity/authorize",
|
||||
state: encodeBase64Url(new TextEncoder().encode(JSON.stringify({ details: { neon_project_name: 'neon-project' } }))),
|
||||
code_challenge: "xf6HY7PIgoaCf_eMniSt-45brYE2J_05C9BnfIbueik",
|
||||
code_challenge_method: "S256",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
cookies = updateCookiesFromResponse(cookies, first);
|
||||
let second = undefined;
|
||||
let third = undefined;
|
||||
if (first.status === 307) {
|
||||
second = await first.follow({ redirect: "manual", headers: { "Cookie": cookies } });
|
||||
cookies = updateCookiesFromResponse(cookies, second);
|
||||
if (second.status === 303) {
|
||||
third = await second.follow({ redirect: "manual", headers: { "Cookie": cookies } });
|
||||
cookies = updateCookiesFromResponse(cookies, third);
|
||||
}
|
||||
}
|
||||
return { responses: [first, second, third], cookies };
|
||||
}
|
||||
|
||||
async function authorizePart2(interactionUid: string, authorizationCode: string, cookies: string) {
|
||||
const first = await niceBackendFetch(`/api/v1/integrations/neon/oauth/idp/interaction/${encodeURIComponent(interactionUid)}/done`, {
|
||||
query: {
|
||||
code: authorizationCode,
|
||||
},
|
||||
headers: {
|
||||
"Cookie": cookies,
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
cookies = updateCookiesFromResponse(cookies, first);
|
||||
let second = undefined;
|
||||
let third = undefined;
|
||||
if (first.status === 200) {
|
||||
second = await niceBackendFetch(`/api/v1/integrations/neon/oauth/idp/interaction/${encodeURIComponent(interactionUid)}/done`, {
|
||||
method: "POST",
|
||||
query: {
|
||||
code: authorizationCode,
|
||||
},
|
||||
headers: {
|
||||
"Cookie": cookies,
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
cookies = updateCookiesFromResponse(cookies, second);
|
||||
if (second.status === 303) {
|
||||
third = await second.follow({
|
||||
headers: { "Cookie": cookies },
|
||||
redirect: "manual",
|
||||
});
|
||||
cookies = updateCookiesFromResponse(cookies, third);
|
||||
}
|
||||
}
|
||||
return { responses: [first, second, third], cookies };
|
||||
}
|
||||
|
||||
async function authorize(projectId: string) {
|
||||
const authorizePart1Response = await authorizePart1();
|
||||
expect(authorizePart1Response.responses).toMatchInlineSnapshot(`
|
||||
[
|
||||
NiceResponse {
|
||||
"status": 307,
|
||||
"headers": Headers {
|
||||
"location": "http://localhost:8102/api/v1/integrations/neon/oauth/idp/auth?response_type=code&client_id=neon-local&redirect_uri=%3Cstripped+query+param%3E&state=%3Cstripped+query+param%3E&code_challenge=%3Cstripped+query+param%3E&code_challenge_method=S256&scope=openid",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
NiceResponse {
|
||||
"status": 303,
|
||||
"body": "Redirecting to <a href=\\"http://localhost:8102/api/v1/integrations/neon/oauth/idp/interaction/<stripped interaction UID>\\">http://localhost:8102/api/v1/integrations/neon/oauth/idp/interaction/<stripped interaction UID></a>.",
|
||||
"headers": Headers {
|
||||
"content-length": "211",
|
||||
"location": "http://localhost:8102/api/v1/integrations/neon/oauth/idp/interaction/<stripped interaction UID>",
|
||||
"set-cookie": <setting cookie "_interaction" at path "/api/v1/integrations/neon/oauth/idp/interaction/<stripped interaction UID>" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_interaction.sig" at path "/api/v1/integrations/neon/oauth/idp/interaction/<stripped interaction UID>" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_interaction_resume" at path "/api/v1/integrations/neon/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_interaction_resume.sig" at path "/api/v1/integrations/neon/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
NiceResponse {
|
||||
"status": 307,
|
||||
"body": "http://localhost:8101/integrations/neon/confirm?interaction_uid=%3Cstripped+query+param%3E&=",
|
||||
"headers": Headers {
|
||||
"content-length": "287",
|
||||
"location": "http://localhost:8101/integrations/neon/confirm?interaction_uid=%3Cstripped+query+param%3E&neon_project_display_name=neon-project",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
const dashboardConfirmUrl = new URL(authorizePart1Response.responses[2]!.headers.get("location")!);
|
||||
const interactionUid = dashboardConfirmUrl.searchParams.get("interaction_uid")!;
|
||||
const confirmResponse = await niceBackendFetch(`/api/v1/integrations/neon/internal/confirm`, {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
project_id: projectId,
|
||||
interaction_uid: interactionUid,
|
||||
},
|
||||
});
|
||||
expect(confirmResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "authorization_code": <stripped field 'authorization_code'> },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
const innerAuthorizationCode = confirmResponse.body.authorization_code;
|
||||
const authorizePart2Response = await authorizePart2(interactionUid, innerAuthorizationCode, authorizePart1Response.cookies);
|
||||
expect(authorizePart2Response.responses).toMatchInlineSnapshot(`
|
||||
[
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": "\\n <html>\\n <body>\\n <form id=\\"continue-form\\" method=\\"POST\\">\\n If you are not redirected, please press the button below.<br>\\n <input type=\\"submit\\" value=\\"Continue\\">\\n </form>\\n <script>\\n document.getElementById('continue-form').style.visibility = 'hidden';\\n document.getElementById('continue-form').submit();\\n setTimeout(() => {\\n document.getElementById('continue-form').style.visibility = 'visible';\\n }, 3000);\\n </script>\\n </body>\\n </html>\\n ",
|
||||
"headers": Headers {
|
||||
"content-length": "674",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
NiceResponse {
|
||||
"status": 303,
|
||||
"headers": Headers {
|
||||
"content-length": "0",
|
||||
"location": "http://localhost:8102/api/v1/integrations/neon/oauth/idp/auth/<stripped auth UID>",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
NiceResponse {
|
||||
"status": 303,
|
||||
"body": "http://localhost:30000/api/v2/identity/authorize?code=%3Cstripped+query+param%3E&=",
|
||||
"headers": Headers {
|
||||
"content-length": "545",
|
||||
"location": "http://localhost:30000/api/v2/identity/authorize?code=%3Cstripped+query+param%3E&state=%3Cstripped+query+param%3E&iss=http%3A%2F%2Flocalhost%3A8102%2Fapi%2Fv1%2Fintegrations%2Fneon%2Foauth%2Fidp",
|
||||
"set-cookie": <setting cookie "_interaction_resume" at path "/api/v1/integrations/neon/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_interaction_resume.sig" at path "/api/v1/integrations/neon/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_session" at path "/" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_session.sig" at path "/" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_session.legacy" at path "/" to <stripped cookie value>>,
|
||||
"set-cookie": <setting cookie "_session.legacy.sig" at path "/" to <stripped cookie value>>,
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
const authorizationCode = new URL(authorizePart2Response.responses[2]!.headers.get("location")!).searchParams.get("code")!;
|
||||
return { authorizationCode };
|
||||
}
|
||||
|
||||
it(`should redirect to the correct callback URL`, async ({}) => {
|
||||
await Auth.Otp.signIn();
|
||||
const createdProject = await Project.create();
|
||||
|
||||
await authorize(createdProject.projectId);
|
||||
});
|
||||
|
||||
it(`should exchange the authorization code for an admin API key that works`, async ({}) => {
|
||||
await Auth.Otp.signIn();
|
||||
const createdProject = await Project.create();
|
||||
|
||||
const { authorizationCode } = await authorize(createdProject.projectId);
|
||||
const tokenResponse = await niceBackendFetch(`/api/v1/integrations/neon/oauth/token`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
code_verifier: "W2LPAD4M4ES-3wBjzU6J5ApykmuxQy5VTs3oSmtboDM",
|
||||
redirect_uri: "http://localhost:30000/api/v2/identity/authorize",
|
||||
},
|
||||
headers: {
|
||||
"Authorization": "Basic bmVvbi1sb2NhbDpuZW9uLWxvY2FsLXNlY3JldA=="
|
||||
},
|
||||
});
|
||||
expect(tokenResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"access_token": <stripped field 'access_token'>,
|
||||
"project_id": "<stripped UUID>",
|
||||
"token_type": "api_key",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
expect(tokenResponse.body.project_id).toBe(createdProject.projectId);
|
||||
const apiKey = tokenResponse.body.access_token;
|
||||
backendContext.set({
|
||||
projectKeys: {
|
||||
projectId: createdProject.projectId,
|
||||
superSecretAdminKey: apiKey,
|
||||
},
|
||||
userAuth: null,
|
||||
});
|
||||
console.log(backendContext.value);
|
||||
const listApiKeysResponse = await ApiKey.listAll();
|
||||
expect(listApiKeysResponse).toMatchInlineSnapshot(`
|
||||
{
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"description": "Auto-generated for Neon",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
(async () => {
|
||||
(async () => {
|
||||
const stackApiUrl = "http://localhost:8102"; // or https://api.stack-auth.com
|
||||
|
||||
// Authorize redirect
|
||||
const authorizeUrl = new URL("/api/v1/integrations/neon/oauth/authorize", stackApiUrl);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", "neon-local");
|
||||
authorizeUrl.searchParams.set("redirect_uri", "http://localhost:30000/api/v2/identity/authorize");
|
||||
authorizeUrl.searchParams.set("state", btoa(JSON.stringify({ details: { neon_project_name: 'neon-project' } })));
|
||||
authorizeUrl.searchParams.set("code_challenge", "xf6HY7PIgoaCf_eMniSt-45brYE2J_05C9BnfIbueik");
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
window.open(authorizeUrl.toString(), "_blank");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Callback
|
||||
const callbackUrl = prompt("A new window should have opened. Please paste the callback URL back here:");
|
||||
if (!callbackUrl) throw new Error("No callback URL provided");
|
||||
const callbackUrlObj = new URL(callbackUrl);
|
||||
if (callbackUrlObj.searchParams.get("state") !== authorizeUrl.searchParams.get("state")) throw new Error("State mismatch");
|
||||
const code = callbackUrlObj.searchParams.get("code");
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
// Token exchange
|
||||
const tokenUrl = new URL("/api/v1/integrations/neon/oauth/token", stackApiUrl);
|
||||
const tokenResponse = await fetch(tokenUrl.toString(), {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: "W2LPAD4M4ES-3wBjzU6J5ApykmuxQy5VTs3oSmtboDM",
|
||||
redirect_uri: authorizeUrl.searchParams.get("redirect_uri"),
|
||||
}).toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": "Basic bmVvbi1sb2NhbDpuZW9uLWxvY2FsLXNlY3JldA=="
|
||||
},
|
||||
});
|
||||
const tokenData = await tokenResponse.json();
|
||||
return tokenData; // { access_token: '...', token_type: 'api_key', project_id: '...' }
|
||||
})();
|
||||
*/
|
||||
@ -8,7 +8,7 @@ describe("without project access", () => {
|
||||
projectKeys: 'no-project'
|
||||
});
|
||||
|
||||
it("should not have have access to api keys", async ({ expect }) => {
|
||||
it("should not have access to api keys", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/internal/api-keys", { accessType: "client" });
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
@ -27,6 +27,29 @@ describe("without project access", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("with server access", () => {
|
||||
it("should not have access to api keys", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/internal/api-keys", { accessType: "server" });
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": {
|
||||
"code": "INSUFFICIENT_ACCESS_TYPE",
|
||||
"details": {
|
||||
"actual_access_type": "server",
|
||||
"allowed_access_types": ["admin"],
|
||||
},
|
||||
"error": "The x-stack-access-type header must be 'admin', but was 'server'.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with admin access to the internal project", () => {
|
||||
it("list api keys", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
@ -164,9 +187,9 @@ describe("with admin access to a non-internal project", () => {
|
||||
"description": "new description",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -198,16 +221,16 @@ describe("with admin access to a non-internal project", () => {
|
||||
"description": "key2",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
{
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"description": "new description",
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -235,9 +258,9 @@ describe("with admin access to a non-internal project", () => {
|
||||
"expires_at_millis": <stripped field 'expires_at_millis'>,
|
||||
"id": "<stripped UUID>",
|
||||
"manually_revoked_at_millis": <stripped field 'manually_revoked_at_millis'>,
|
||||
"publishable_client_key": <stripped field 'publishable_client_key'>,
|
||||
"secret_server_key": <stripped field 'secret_server_key'>,
|
||||
"super_secret_admin_key": <stripped field 'super_secret_admin_key'>,
|
||||
"publishable_client_key": { "last_four": <stripped field 'last_four'> },
|
||||
"secret_server_key": { "last_four": <stripped field 'last_four'> },
|
||||
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
|
||||
@ -129,6 +129,7 @@ export class NiceResponse implements Nicifiable {
|
||||
public readonly status: number,
|
||||
public readonly headers: Headers,
|
||||
public readonly body: any,
|
||||
public readonly fromRequestInit?: NiceRequestInit,
|
||||
) {}
|
||||
|
||||
getNicifiableKeys(): string[] {
|
||||
@ -139,6 +140,25 @@ export class NiceResponse implements Nicifiable {
|
||||
"headers",
|
||||
];
|
||||
}
|
||||
|
||||
async follow(options?: NiceRequestInit) {
|
||||
if (![301, 302, 303, 307, 308].includes(this.status)) {
|
||||
throw new StackAssertionError(`Cannot follow non-redirect response: ${this.status}`);
|
||||
}
|
||||
const location = this.headers.get("Location");
|
||||
if (!location) {
|
||||
throw new StackAssertionError(`Redirect response has no Location header: ${this.status}`);
|
||||
}
|
||||
const followRes = await niceFetch(location, {
|
||||
...[301, 302, 303].includes(this.status) ? { method: "GET" } : {
|
||||
body: this.fromRequestInit?.body,
|
||||
method: this.fromRequestInit?.method,
|
||||
headers: this.fromRequestInit?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return followRes;
|
||||
}
|
||||
};
|
||||
|
||||
export type NiceRequestInit = RequestInit & {
|
||||
@ -161,7 +181,7 @@ export async function niceFetch(url: string | URL, options?: NiceRequestInit): P
|
||||
} else {
|
||||
body = await fetchRes.arrayBuffer();
|
||||
}
|
||||
return new NiceResponse(fetchRes.status, fetchRes.headers, body);
|
||||
return new NiceResponse(fetchRes.status, fetchRes.headers, body, options);
|
||||
}
|
||||
|
||||
export const localRedirectUrl = "http://stack-test.localhost/some-callback-url";
|
||||
|
||||
@ -37,15 +37,27 @@ const stripFields = [
|
||||
"expires_at_millis",
|
||||
"created_at_millis",
|
||||
"manually_revoked_at_millis",
|
||||
"last_four",
|
||||
"attempt_code",
|
||||
"nonce",
|
||||
"authorization_code",
|
||||
] as const;
|
||||
|
||||
const stripFieldsIfString = [
|
||||
"publishable_client_key",
|
||||
"secret_server_key",
|
||||
"super_secret_admin_key",
|
||||
"attempt_code",
|
||||
"nonce",
|
||||
] as const;
|
||||
|
||||
const keyedCookieNamePrefixes = [
|
||||
"stack-oauth-inner-",
|
||||
const stripCookies = [
|
||||
"_interaction",
|
||||
"_interaction.sig",
|
||||
"_interaction_resume",
|
||||
"_interaction_resume.sig",
|
||||
"_session",
|
||||
"_session.sig",
|
||||
"_session.legacy",
|
||||
"_session.legacy.sig",
|
||||
] as const;
|
||||
|
||||
const stripUrlQueryParams = [
|
||||
@ -53,6 +65,15 @@ const stripUrlQueryParams = [
|
||||
"state",
|
||||
"code",
|
||||
"code_challenge",
|
||||
"interaction_uid",
|
||||
] as const;
|
||||
|
||||
const keyedCookieNamePrefixes = [
|
||||
"stack-oauth-inner-",
|
||||
] as const;
|
||||
|
||||
const stringRegexReplacements = [
|
||||
[/(\/integrations\/neon\/oauth\/idp\/(interaction|auth)\/)[a-zA-Z0-9_-]+/gi, "$1<stripped $2 UID>"],
|
||||
] as const;
|
||||
|
||||
function addAll<T>(set: Set<T>, values: readonly T[]) {
|
||||
@ -73,6 +94,14 @@ const snapshotSerializer: SnapshotSerializer = {
|
||||
overrides: (value, options) => {
|
||||
const parentValue = options?.parent?.value;
|
||||
|
||||
// Strip all string regex replacements
|
||||
if (typeof value === "string") {
|
||||
for (const [regex, replacement] of stringRegexReplacements) {
|
||||
const newValue: string = value.replace(regex, replacement);
|
||||
if (newValue !== value) return nicify(newValue, options);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip all UUIDs except all-zero UUID
|
||||
if (typeof value === "string") {
|
||||
const newValue = value.replace(
|
||||
@ -83,22 +112,28 @@ const snapshotSerializer: SnapshotSerializer = {
|
||||
}
|
||||
|
||||
// Strip URL query params
|
||||
if (typeof value === "string" && (value.startsWith("http://") || value.startsWith("https://"))) {
|
||||
const url = new URL(value);
|
||||
for (const param of stripUrlQueryParams) {
|
||||
if (url.searchParams.has(param)) {
|
||||
url.searchParams.set(param, "<stripped-query-param>");
|
||||
const urlRegexHeuristic = /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#\/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#\/%=~_|$?!:,.]*\)|[A-Z0-9+&@#\/%=~_|$])/igm;
|
||||
if (typeof value === "string") {
|
||||
for (const urlMatch of value.matchAll(urlRegexHeuristic)) {
|
||||
const questionMarkIndex = urlMatch[0].indexOf("?");
|
||||
if (questionMarkIndex >= 0) {
|
||||
const searchParamsObj = new URLSearchParams(urlMatch[0].slice(questionMarkIndex + 1));
|
||||
for (const param of stripUrlQueryParams) {
|
||||
if (searchParamsObj.has(param)) {
|
||||
searchParamsObj.set(param, "<stripped query param>");
|
||||
}
|
||||
}
|
||||
let newValue = `${urlMatch[0].slice(0, questionMarkIndex)}?${searchParamsObj.toString()}`;
|
||||
if (urlMatch[0].endsWith("/") !== newValue.endsWith("/")) {
|
||||
if (urlMatch[0].endsWith("/")) {
|
||||
newValue += "/";
|
||||
} else {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (newValue !== value) return nicify(newValue, options);
|
||||
}
|
||||
}
|
||||
let newValue = url.toString();
|
||||
if (value.endsWith("/") !== newValue.endsWith("/")) {
|
||||
if (value.endsWith("/")) {
|
||||
newValue += "/";
|
||||
} else {
|
||||
newValue = newValue.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (newValue !== value) return nicify(newValue, options);
|
||||
}
|
||||
|
||||
// Strip headers
|
||||
@ -125,7 +160,7 @@ const snapshotSerializer: SnapshotSerializer = {
|
||||
if (expiresDate.getTime() < new Date("2001-01-01").getTime()) {
|
||||
return `<deleting cookie '${cookieName}' at path '${parts.get("Path") ?? "/"}'>`;
|
||||
} else {
|
||||
return `<setting cookie ${JSON.stringify(cookieName)} at path ${JSON.stringify(parts.get("Path") ?? "/")} to ${JSON.stringify(cookieValue)}>`;
|
||||
return `<setting cookie ${JSON.stringify(cookieName)} at path ${JSON.stringify(parts.get("path") ?? "/")} to ${typedIncludes(stripCookies, cookieName) ? "<stripped cookie value>" : JSON.stringify(cookieValue)}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -163,7 +198,8 @@ const snapshotSerializer: SnapshotSerializer = {
|
||||
return `<stripped field '${options.keyInParent.toString()}'>`;
|
||||
}
|
||||
}
|
||||
if (typedIncludes(stripFields, options?.keyInParent)) {
|
||||
const allStripFields = [...stripFields, ...typeof value === "string" ? stripFieldsIfString : []];
|
||||
if (typedIncludes(allStripFields, options?.keyInParent)) {
|
||||
return `<stripped field '${options.keyInParent}'>`;
|
||||
}
|
||||
|
||||
|
||||
@ -97,6 +97,16 @@ export function decodeBase64Url(input: string): Uint8Array {
|
||||
return decodeBase64(input.replace(/-/g, "+").replace(/_/g, "/") + "====".slice((input.length - 1) % 4 + 1));
|
||||
}
|
||||
|
||||
export function decodeBase64OrBase64Url(input: string): Uint8Array {
|
||||
if (isBase64Url(input)) {
|
||||
return decodeBase64Url(input);
|
||||
} else if (isBase64(input)) {
|
||||
return decodeBase64(input);
|
||||
} else {
|
||||
throw new StackAssertionError("Invalid base64 or base64url string");
|
||||
}
|
||||
}
|
||||
|
||||
export function isBase32(input: string): boolean {
|
||||
for (const char of input) {
|
||||
if (char === " ") continue;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user