Skip to content

Commit cec718f

Browse files
N2D4bazumo
andauthored
Identity Provider/External OAuth (stack-auth#323)
Co-authored-by: moritz <moritsch@student.ethz.ch>
1 parent 3b786ea commit cec718f

File tree

34 files changed

+1073
-119
lines changed

34 files changed

+1073
-119
lines changed

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"typescript.tsdk": "node_modules/typescript/lib",
88
"editor.tabSize": 2,
99
"cSpell.words": [
10+
"Cdfc",
1011
"cjsx",
1112
"clsx",
1213
"cmdk",
@@ -44,6 +45,7 @@
4445
"pkcco",
4546
"PKCE",
4647
"posthog",
48+
"preconfigured",
4749
"Proxied",
4850
"psql",
4951
"qrcode",

apps/backend/.env

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Basic
2-
STACK_BASE_URL=# enter the URL of the backend here. For local development, use `http://localhost:8102`.
2+
STACK_BASE_URL=# the base URL of Stack's backend/API. For local development, this is `http://localhost:8102`; for the managed service, this is `https://api.stack-auth.com`.
3+
NEXT_PUBLIC_STACK_DASHBOARD_URL=# the URL of Stack's dashboard. For local development, this is `http://localhost:8101`; for the managed service, this is `https://app.stack-auth.com`.
34
STACK_SERVER_SECRET=# enter a secret key generated by `pnpm generate-keys` here. This is used to sign the JWT tokens.
45

56
# OAuth mock provider settings
@@ -37,3 +38,4 @@ STACK_SVIX_API_KEY=# enter the API key for the Svix webhook service here. Use `e
3738
STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access token here. Optional, don't specify it for default value
3839
STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value
3940
OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, default is `http://localhost:4318`
41+
STACK_NEON_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for the Neon integration. If not provided, disables Neon integration

apps/backend/.env.development

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
STACK_BASE_URL=http://localhost:8102
2+
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101
23
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
34

45
STACK_OAUTH_MOCK_URL=http://localhost:8114
@@ -28,3 +29,5 @@ STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2Mzks
2829
STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=100
2930

3031
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
32+
33+
STACK_NEON_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "neon-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize"]}]

apps/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"jose": "^5.2.2",
5656
"next": "^14.2.5",
5757
"nodemailer": "^6.9.10",
58+
"oidc-provider": "^8.5.1",
5859
"openid-client": "^5.6.4",
5960
"oslo": "^1.2.1",
6061
"pg": "^8.11.3",
@@ -72,6 +73,7 @@
7273
"@simplewebauthn/types": "^11.0.0",
7374
"@types/node": "^20.8.10",
7475
"@types/nodemailer": "^6.4.14",
76+
"@types/oidc-provider": "^8.5.1",
7577
"@types/react": "^18.3.12",
7678
"@types/semver": "^7.5.8",
7779
"concurrently": "^8.2.2",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- CreateTable
2+
CREATE TABLE "IdPAccountToCdfcResultMapping" (
3+
"idpId" TEXT NOT NULL,
4+
"id" TEXT NOT NULL,
5+
"idpAccountId" UUID NOT NULL,
6+
"cdfcResult" JSONB NOT NULL,
7+
8+
CONSTRAINT "IdPAccountToCdfcResultMapping_pkey" PRIMARY KEY ("idpId","id")
9+
);
10+
11+
-- CreateTable
12+
CREATE TABLE "ProjectWrapperCodes" (
13+
"idpId" TEXT NOT NULL,
14+
"id" UUID NOT NULL,
15+
"interactionUid" TEXT NOT NULL,
16+
"authorizationCode" TEXT NOT NULL,
17+
"cdfcResult" JSONB NOT NULL,
18+
19+
CONSTRAINT "ProjectWrapperCodes_pkey" PRIMARY KEY ("idpId","id")
20+
);
21+
22+
-- CreateTable
23+
CREATE TABLE "IdPAdapterData" (
24+
"idpId" TEXT NOT NULL,
25+
"model" TEXT NOT NULL,
26+
"id" TEXT NOT NULL,
27+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
28+
"updatedAt" TIMESTAMP(3) NOT NULL,
29+
"payload" JSONB NOT NULL,
30+
"expiresAt" TIMESTAMP(3) NOT NULL,
31+
32+
CONSTRAINT "IdPAdapterData_pkey" PRIMARY KEY ("idpId","model","id")
33+
);
34+
35+
-- CreateIndex
36+
CREATE UNIQUE INDEX "IdPAccountToCdfcResultMapping_idpAccountId_key" ON "IdPAccountToCdfcResultMapping"("idpAccountId");
37+
38+
-- CreateIndex
39+
CREATE UNIQUE INDEX "ProjectWrapperCodes_authorizationCode_key" ON "ProjectWrapperCodes"("authorizationCode");
40+
41+
-- CreateIndex
42+
CREATE INDEX "IdPAdapterData_payload_idx" ON "IdPAdapterData" USING GIN ("payload" jsonb_path_ops);
43+
44+
-- CreateIndex
45+
CREATE INDEX "IdPAdapterData_expiresAt_idx" ON "IdPAdapterData"("expiresAt");

apps/backend/prisma/schema.prisma

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,46 @@ model StandardEmailServiceConfig {
796796

797797
//#endregion
798798

799+
//#region IdP
800+
model IdPAccountToCdfcResultMapping {
801+
idpId String
802+
id String
803+
804+
idpAccountId String @db.Uuid @unique
805+
cdfcResult Json
806+
807+
@@id([idpId, id])
808+
}
809+
810+
model ProjectWrapperCodes {
811+
idpId String
812+
id String @default(uuid()) @db.Uuid
813+
814+
interactionUid String
815+
authorizationCode String @unique
816+
817+
cdfcResult Json
818+
819+
@@id([idpId, id])
820+
}
821+
822+
model IdPAdapterData {
823+
idpId String
824+
model String
825+
id String
826+
827+
createdAt DateTime @default(now())
828+
updatedAt DateTime @updatedAt
829+
830+
payload Json
831+
expiresAt DateTime
832+
833+
@@id([idpId, model, id])
834+
@@index([payload(ops: JsonbPathOps)], type: Gin)
835+
@@index([expiresAt])
836+
}
837+
//#endregion
838+
799839
//#region Events
800840

801841
model Event {

apps/backend/src/app/api/v1/integrations/neon/api-keys/route.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createApiKeySet } from "@/lib/api-keys";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3-
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
44
import { apiKeyCrudHandlers } from "./crud";
55

6+
67
export const GET = apiKeyCrudHandlers.listHandler;
78

89
export const POST = createSmartRouteHandler({
@@ -11,7 +12,7 @@ export const POST = createSmartRouteHandler({
1112
},
1213
request: yupObject({
1314
auth: yupObject({
14-
type: clientOrHigherAuthTypeSchema,
15+
type: adminAuthTypeSchema,
1516
project: adaptSchema.defined(),
1617
}).defined(),
1718
body: yupObject({
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { prismaClient } from "@/prisma-client";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
5+
6+
export const POST = createSmartRouteHandler({
7+
metadata: {
8+
hidden: true,
9+
},
10+
request: yupObject({
11+
url: yupString().defined(),
12+
auth: yupObject({
13+
project: yupObject({
14+
id: yupString().oneOf(["internal"]).defined(),
15+
}).defined(),
16+
type: serverOrHigherAuthTypeSchema.defined(),
17+
}).defined(),
18+
body: yupObject({
19+
interaction_uid: yupString().defined(),
20+
project_id: yupString().defined(),
21+
}).defined(),
22+
}),
23+
response: yupObject({
24+
statusCode: yupNumber().oneOf([200]).defined(),
25+
bodyType: yupString().oneOf(["json"]).defined(),
26+
body: yupObject({
27+
authorization_code: yupString().defined(),
28+
}).defined(),
29+
}),
30+
handler: async (req) => {
31+
// Create an admin API key for the project
32+
const set = await prismaClient.apiKeySet.create({
33+
data: {
34+
projectId: req.body.project_id,
35+
description: "API key for Neon x Stack Auth integration",
36+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100),
37+
superSecretAdminKey: `sak_${generateSecureRandomString()}`,
38+
},
39+
});
40+
41+
// Create authorization code
42+
const authorizationCode = generateSecureRandomString();
43+
await prismaClient.projectWrapperCodes.create({
44+
data: {
45+
idpId: "stack-preconfigured-idp:integrations/neon",
46+
interactionUid: req.body.interaction_uid,
47+
authorizationCode,
48+
cdfcResult: {
49+
access_token: set.superSecretAdminKey,
50+
token_type: "api_key",
51+
project_id: req.body.project_id,
52+
},
53+
},
54+
});
55+
56+
return {
57+
statusCode: 200,
58+
bodyType: "json",
59+
body: {
60+
authorization_code: authorizationCode,
61+
},
62+
};
63+
},
64+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { jsonStringSchema, yupNever, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
4+
import { redirect } from "next/navigation";
5+
6+
export const GET = createSmartRouteHandler({
7+
metadata: {
8+
hidden: true,
9+
},
10+
request: yupObject({
11+
url: yupString().defined(),
12+
query: yupObject({
13+
client_id: yupString().defined(),
14+
redirect_uri: yupString().defined(),
15+
state: jsonStringSchema.defined(),
16+
code_challenge: yupString().defined(),
17+
code_challenge_method: yupString().oneOf(["S256"]).defined(),
18+
response_type: yupString().oneOf(["code"]).defined(),
19+
}).defined(),
20+
}),
21+
response: yupNever(),
22+
handler: async (req) => {
23+
const url = new URL(req.url);
24+
if (url.pathname !== "/api/v1/integrations/neon/oauth/authorize") {
25+
throw new StackAssertionError(`Expected pathname to be authorize endpoint but got ${JSON.stringify(url.pathname)}`, { url });
26+
}
27+
url.pathname = "/api/v1/integrations/neon/oauth/idp/auth";
28+
url.search = new URLSearchParams({ ...req.query, scope: "openid" }).toString();
29+
redirect(url.toString());
30+
},
31+
});

0 commit comments

Comments
 (0)