@quatronic/sdk 0.0.0-canary-20260526130515
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/auth/index.d.ts +34 -0
- package/dist/auth/index.js +83 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/bff/auth/index.d.ts +120 -0
- package/dist/bff/auth/index.js +525 -0
- package/dist/bff/auth/index.js.map +1 -0
- package/dist/bff/auth/migrations/0001_init.sql +31 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @quatronic/sdk
|
|
2
|
+
|
|
3
|
+
Single-package SDK for QDC tenant apps. Exposes:
|
|
4
|
+
|
|
5
|
+
- `@quatronic/sdk/auth` — React hooks and components for the frontend.
|
|
6
|
+
- `@quatronic/sdk/bff/auth` — Hono handlers and middleware for the Node BFF.
|
|
7
|
+
|
|
8
|
+
See `docs/superpowers/specs/2026-05-26-per-app-bff-architecture-design.md`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import * as react from 'react';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
declare function PortalProvider({ children }: {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
declare function RequireAuth({ children, fallback, }: {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
fallback?: ReactNode;
|
|
12
|
+
}): react_jsx_runtime.JSX.Element;
|
|
13
|
+
|
|
14
|
+
type AuthUser = {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string | null;
|
|
17
|
+
organization: {
|
|
18
|
+
id: string | null;
|
|
19
|
+
alias: string | null;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
type AuthStatus = "loading" | "authenticated" | "unauthenticated";
|
|
23
|
+
type AuthContextValue = {
|
|
24
|
+
status: AuthStatus;
|
|
25
|
+
user: AuthUser | null;
|
|
26
|
+
login: (redirect?: string) => void;
|
|
27
|
+
logout: () => Promise<void>;
|
|
28
|
+
reload: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
declare const AuthContext: react.Context<AuthContextValue | null>;
|
|
32
|
+
declare function useAuth(): AuthContextValue;
|
|
33
|
+
|
|
34
|
+
export { AuthContext, type AuthContextValue, type AuthStatus, type AuthUser, PortalProvider, RequireAuth, useAuth };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/auth/PortalProvider.tsx
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/auth/useAuth.ts
|
|
5
|
+
import { createContext, useContext } from "react";
|
|
6
|
+
var AuthContext = createContext(null);
|
|
7
|
+
function useAuth() {
|
|
8
|
+
const ctx = useContext(AuthContext);
|
|
9
|
+
if (!ctx) throw new Error("useAuth must be used inside <PortalProvider>");
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/auth/PortalProvider.tsx
|
|
14
|
+
import { jsx } from "react/jsx-runtime";
|
|
15
|
+
function PortalProvider({ children }) {
|
|
16
|
+
const [status, setStatus] = useState("loading");
|
|
17
|
+
const [user, setUser] = useState(null);
|
|
18
|
+
async function fetchMe() {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch("/api/auth/me", { credentials: "include" });
|
|
21
|
+
if (res.status === 401) {
|
|
22
|
+
setStatus("unauthenticated");
|
|
23
|
+
setUser(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
setStatus("unauthenticated");
|
|
28
|
+
setUser(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const body = await res.json();
|
|
32
|
+
setUser({
|
|
33
|
+
id: body.user.id,
|
|
34
|
+
email: body.user.email,
|
|
35
|
+
organization: body.organization
|
|
36
|
+
});
|
|
37
|
+
setStatus("authenticated");
|
|
38
|
+
} catch {
|
|
39
|
+
setStatus("unauthenticated");
|
|
40
|
+
setUser(null);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void fetchMe();
|
|
45
|
+
}, []);
|
|
46
|
+
const value = {
|
|
47
|
+
status,
|
|
48
|
+
user,
|
|
49
|
+
login: (redirect) => {
|
|
50
|
+
const url = "/api/auth/login" + (redirect ? `?redirect=${encodeURIComponent(redirect)}` : "");
|
|
51
|
+
window.location.href = url;
|
|
52
|
+
},
|
|
53
|
+
logout: async () => {
|
|
54
|
+
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
|
55
|
+
setStatus("unauthenticated");
|
|
56
|
+
setUser(null);
|
|
57
|
+
},
|
|
58
|
+
reload: fetchMe
|
|
59
|
+
};
|
|
60
|
+
return /* @__PURE__ */ jsx(AuthContext.Provider, { value, children });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/auth/RequireAuth.tsx
|
|
64
|
+
import { useEffect as useEffect2 } from "react";
|
|
65
|
+
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
66
|
+
function RequireAuth({
|
|
67
|
+
children,
|
|
68
|
+
fallback
|
|
69
|
+
}) {
|
|
70
|
+
const { status, login } = useAuth();
|
|
71
|
+
useEffect2(() => {
|
|
72
|
+
if (status === "unauthenticated") login();
|
|
73
|
+
}, [status, login]);
|
|
74
|
+
if (status === "authenticated") return /* @__PURE__ */ jsx2(Fragment, { children });
|
|
75
|
+
return /* @__PURE__ */ jsx2(Fragment, { children: fallback ?? null });
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
AuthContext,
|
|
79
|
+
PortalProvider,
|
|
80
|
+
RequireAuth,
|
|
81
|
+
useAuth
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/auth/PortalProvider.tsx","../../src/auth/useAuth.ts","../../src/auth/RequireAuth.tsx"],"sourcesContent":["import { useEffect, useState, type ReactNode } from \"react\"\nimport { AuthContext } from \"./useAuth\"\nimport type { AuthContextValue, AuthStatus, AuthUser } from \"./types\"\n\ntype MeResponse = {\n user: { id: string; email: string | null }\n organization: { id: string | null; alias: string | null }\n}\n\nexport function PortalProvider({ children }: { children: ReactNode }) {\n const [status, setStatus] = useState<AuthStatus>(\"loading\")\n const [user, setUser] = useState<AuthUser | null>(null)\n\n async function fetchMe(): Promise<void> {\n try {\n const res = await fetch(\"/api/auth/me\", { credentials: \"include\" })\n if (res.status === 401) {\n setStatus(\"unauthenticated\"); setUser(null); return\n }\n if (!res.ok) {\n setStatus(\"unauthenticated\"); setUser(null); return\n }\n const body = (await res.json()) as MeResponse\n setUser({\n id: body.user.id,\n email: body.user.email,\n organization: body.organization,\n })\n setStatus(\"authenticated\")\n } catch {\n setStatus(\"unauthenticated\"); setUser(null)\n }\n }\n\n useEffect(() => { void fetchMe() }, [])\n\n const value: AuthContextValue = {\n status, user,\n login: (redirect?: string) => {\n const url = \"/api/auth/login\" + (redirect ? `?redirect=${encodeURIComponent(redirect)}` : \"\")\n window.location.href = url\n },\n logout: async () => {\n await fetch(\"/api/auth/logout\", { method: \"POST\", credentials: \"include\" })\n setStatus(\"unauthenticated\"); setUser(null)\n },\n reload: fetchMe,\n }\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>\n}\n","import { createContext, useContext } from \"react\"\nimport type { AuthContextValue } from \"./types\"\n\nexport const AuthContext = createContext<AuthContextValue | null>(null)\n\nexport function useAuth(): AuthContextValue {\n const ctx = useContext(AuthContext)\n if (!ctx) throw new Error(\"useAuth must be used inside <PortalProvider>\")\n return ctx\n}\n","import { useEffect, type ReactNode } from \"react\"\nimport { useAuth } from \"./useAuth\"\n\nexport function RequireAuth({\n children,\n fallback,\n}: {\n children: ReactNode\n fallback?: ReactNode\n}) {\n const { status, login } = useAuth()\n useEffect(() => {\n if (status === \"unauthenticated\") login()\n }, [status, login])\n\n if (status === \"authenticated\") return <>{children}</>\n return <>{fallback ?? null}</>\n}\n"],"mappings":";AAAA,SAAS,WAAW,gBAAgC;;;ACApD,SAAS,eAAe,kBAAkB;AAGnC,IAAM,cAAc,cAAuC,IAAI;AAE/D,SAAS,UAA4B;AAC1C,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,8CAA8C;AACxE,SAAO;AACT;;;ADwCS;AAxCF,SAAS,eAAe,EAAE,SAAS,GAA4B;AACpE,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAqB,SAAS;AAC1D,QAAM,CAAC,MAAM,OAAO,IAAI,SAA0B,IAAI;AAEtD,iBAAe,UAAyB;AACtC,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,gBAAgB,EAAE,aAAa,UAAU,CAAC;AAClE,UAAI,IAAI,WAAW,KAAK;AACtB,kBAAU,iBAAiB;AAAG,gBAAQ,IAAI;AAAG;AAAA,MAC/C;AACA,UAAI,CAAC,IAAI,IAAI;AACX,kBAAU,iBAAiB;AAAG,gBAAQ,IAAI;AAAG;AAAA,MAC/C;AACA,YAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,cAAQ;AAAA,QACN,IAAI,KAAK,KAAK;AAAA,QACd,OAAO,KAAK,KAAK;AAAA,QACjB,cAAc,KAAK;AAAA,MACrB,CAAC;AACD,gBAAU,eAAe;AAAA,IAC3B,QAAQ;AACN,gBAAU,iBAAiB;AAAG,cAAQ,IAAI;AAAA,IAC5C;AAAA,EACF;AAEA,YAAU,MAAM;AAAE,SAAK,QAAQ;AAAA,EAAE,GAAG,CAAC,CAAC;AAEtC,QAAM,QAA0B;AAAA,IAC9B;AAAA,IAAQ;AAAA,IACR,OAAO,CAAC,aAAsB;AAC5B,YAAM,MAAM,qBAAqB,WAAW,aAAa,mBAAmB,QAAQ,CAAC,KAAK;AAC1F,aAAO,SAAS,OAAO;AAAA,IACzB;AAAA,IACA,QAAQ,YAAY;AAClB,YAAM,MAAM,oBAAoB,EAAE,QAAQ,QAAQ,aAAa,UAAU,CAAC;AAC1E,gBAAU,iBAAiB;AAAG,cAAQ,IAAI;AAAA,IAC5C;AAAA,IACA,QAAQ;AAAA,EACV;AAEA,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAe,UAAS;AACvD;;;AElDA,SAAS,aAAAA,kBAAiC;AAeD,0BAAAC,YAAA;AAZlC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,QAAQ,MAAM,IAAI,QAAQ;AAClC,EAAAC,WAAU,MAAM;AACd,QAAI,WAAW,kBAAmB,OAAM;AAAA,EAC1C,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,MAAI,WAAW,gBAAiB,QAAO,gBAAAD,KAAA,YAAG,UAAS;AACnD,SAAO,gBAAAA,KAAA,YAAG,sBAAY,MAAK;AAC7B;","names":["useEffect","jsx","useEffect"]}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { MiddlewareHandler, Hono } from 'hono';
|
|
2
|
+
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
3
|
+
|
|
4
|
+
type KcConfig = {
|
|
5
|
+
keycloakUrl: string;
|
|
6
|
+
realm: string;
|
|
7
|
+
clientId: string;
|
|
8
|
+
clientSecret: string;
|
|
9
|
+
publicOrigin: string;
|
|
10
|
+
orgAlias?: string;
|
|
11
|
+
};
|
|
12
|
+
type KcTokens = {
|
|
13
|
+
accessToken: string;
|
|
14
|
+
refreshToken: string;
|
|
15
|
+
idToken: string;
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
refreshExpiresIn: number;
|
|
18
|
+
};
|
|
19
|
+
type KcClient = ReturnType<typeof createKcClient>;
|
|
20
|
+
declare function createKcClient(cfg: KcConfig): {
|
|
21
|
+
buildAuthorizeUrl: (args: {
|
|
22
|
+
state: string;
|
|
23
|
+
codeChallenge: string;
|
|
24
|
+
nonce?: string;
|
|
25
|
+
}) => string;
|
|
26
|
+
exchangeCode: (args: {
|
|
27
|
+
code: string;
|
|
28
|
+
codeVerifier: string;
|
|
29
|
+
}) => Promise<KcTokens>;
|
|
30
|
+
refresh: (refreshToken: string) => Promise<KcTokens>;
|
|
31
|
+
logout: (refreshToken: string) => Promise<void>;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
scope: () => string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Cipher = {
|
|
37
|
+
key: CryptoKey;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type CreateSessionInput = {
|
|
41
|
+
userSub: string;
|
|
42
|
+
userEmail?: string;
|
|
43
|
+
organizationId?: string;
|
|
44
|
+
organizationAlias?: string;
|
|
45
|
+
accessToken: string;
|
|
46
|
+
accessExpiresAt: Date;
|
|
47
|
+
refreshToken: string;
|
|
48
|
+
refreshExpiresAt: Date;
|
|
49
|
+
};
|
|
50
|
+
type Session = {
|
|
51
|
+
id: string;
|
|
52
|
+
userSub: string;
|
|
53
|
+
userEmail: string | null;
|
|
54
|
+
organizationId: string | null;
|
|
55
|
+
organizationAlias: string | null;
|
|
56
|
+
accessToken: string;
|
|
57
|
+
accessExpiresAt: Date;
|
|
58
|
+
refreshToken: string;
|
|
59
|
+
refreshExpiresAt: Date;
|
|
60
|
+
};
|
|
61
|
+
type RefreshStore = ReturnType<typeof createRefreshStore>;
|
|
62
|
+
declare function createRefreshStore(opts: {
|
|
63
|
+
db: NodePgDatabase;
|
|
64
|
+
cipher: Cipher;
|
|
65
|
+
}): {
|
|
66
|
+
createSession: (input: CreateSessionInput) => Promise<string>;
|
|
67
|
+
getSession: (id: string) => Promise<Session | null>;
|
|
68
|
+
rotateTokens: (id: string, t: {
|
|
69
|
+
accessToken: string;
|
|
70
|
+
accessExpiresAt: Date;
|
|
71
|
+
refreshToken: string;
|
|
72
|
+
refreshExpiresAt: Date;
|
|
73
|
+
}) => Promise<void>;
|
|
74
|
+
revokeSession: (id: string) => Promise<void>;
|
|
75
|
+
revokeByUser: (userSub: string) => Promise<void>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type Cookies = {
|
|
79
|
+
read: (cookieHeader: string | null | undefined) => Promise<string | null>;
|
|
80
|
+
};
|
|
81
|
+
type AuthUser = {
|
|
82
|
+
id: string;
|
|
83
|
+
email: string | null;
|
|
84
|
+
organization: {
|
|
85
|
+
id: string | null;
|
|
86
|
+
alias: string | null;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
declare module "hono" {
|
|
90
|
+
interface ContextVariableMap {
|
|
91
|
+
user: AuthUser;
|
|
92
|
+
session: Session;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
declare function requireAuth(opts: {
|
|
96
|
+
cookies: Cookies;
|
|
97
|
+
refreshStore: RefreshStore;
|
|
98
|
+
kcClient: KcClient;
|
|
99
|
+
refreshSkewSec?: number;
|
|
100
|
+
}): MiddlewareHandler;
|
|
101
|
+
|
|
102
|
+
type AttachAuthOptions = {
|
|
103
|
+
keycloakUrl: string;
|
|
104
|
+
realm: string;
|
|
105
|
+
clientId: string;
|
|
106
|
+
clientSecret: string;
|
|
107
|
+
orgAlias?: string;
|
|
108
|
+
publicOrigin: string;
|
|
109
|
+
sessionCookieSecret: string;
|
|
110
|
+
db: NodePgDatabase;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type AttachedAuth = {
|
|
114
|
+
requireAuth: ReturnType<typeof requireAuth>;
|
|
115
|
+
};
|
|
116
|
+
declare function attachAuth(app: Hono, opts: AttachAuthOptions): Promise<AttachedAuth>;
|
|
117
|
+
|
|
118
|
+
declare function runAuthMigrations(db: NodePgDatabase): Promise<void>;
|
|
119
|
+
|
|
120
|
+
export { type AttachAuthOptions, type AttachedAuth, type AuthUser, type Session, attachAuth, requireAuth, runAuthMigrations };
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// src/bff/auth/crypto.ts
|
|
2
|
+
import { webcrypto } from "crypto";
|
|
3
|
+
var SUBTLE = webcrypto.subtle;
|
|
4
|
+
async function createCipher(hexSecret) {
|
|
5
|
+
if (hexSecret.length < 32) {
|
|
6
|
+
throw new Error("SESSION_COOKIE_SECRET must be at least 32 hex chars");
|
|
7
|
+
}
|
|
8
|
+
const raw = Buffer.from(hexSecret, "hex");
|
|
9
|
+
const baseKey = await SUBTLE.importKey("raw", raw, "HKDF", false, ["deriveKey"]);
|
|
10
|
+
const key = await SUBTLE.deriveKey(
|
|
11
|
+
{
|
|
12
|
+
name: "HKDF",
|
|
13
|
+
hash: "SHA-256",
|
|
14
|
+
salt: new Uint8Array(16),
|
|
15
|
+
info: new TextEncoder().encode("qdc-sdk:refresh-token:v1")
|
|
16
|
+
},
|
|
17
|
+
baseKey,
|
|
18
|
+
{ name: "AES-GCM", length: 256 },
|
|
19
|
+
false,
|
|
20
|
+
["encrypt", "decrypt"]
|
|
21
|
+
);
|
|
22
|
+
return { key };
|
|
23
|
+
}
|
|
24
|
+
async function encrypt(cipher, plaintext) {
|
|
25
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
26
|
+
const enc = await SUBTLE.encrypt(
|
|
27
|
+
{ name: "AES-GCM", iv },
|
|
28
|
+
cipher.key,
|
|
29
|
+
new TextEncoder().encode(plaintext)
|
|
30
|
+
);
|
|
31
|
+
return { ciphertext: new Uint8Array(enc), iv };
|
|
32
|
+
}
|
|
33
|
+
async function decrypt(cipher, ciphertext, iv) {
|
|
34
|
+
const dec = await SUBTLE.decrypt({ name: "AES-GCM", iv }, cipher.key, ciphertext);
|
|
35
|
+
return new TextDecoder().decode(dec);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/bff/auth/jwks.ts
|
|
39
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
40
|
+
function createJwks(opts) {
|
|
41
|
+
const url = new URL(`${opts.keycloakUrl}/realms/${opts.realm}/protocol/openid-connect/certs`);
|
|
42
|
+
const jwkset = createRemoteJWKSet(url, {
|
|
43
|
+
cooldownDuration: opts.cooldownMs ?? 3e4
|
|
44
|
+
});
|
|
45
|
+
const issuer = `${opts.keycloakUrl}/realms/${opts.realm}`;
|
|
46
|
+
async function verify(token) {
|
|
47
|
+
const { payload } = await jwtVerify(token, jwkset, {
|
|
48
|
+
audience: opts.audience,
|
|
49
|
+
issuer
|
|
50
|
+
});
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
return { verify };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/bff/auth/kcClient.ts
|
|
57
|
+
function createKcClient(cfg) {
|
|
58
|
+
const issuer = `${cfg.keycloakUrl}/realms/${cfg.realm}`;
|
|
59
|
+
const tokenUrl = `${issuer}/protocol/openid-connect/token`;
|
|
60
|
+
const logoutUrl = `${issuer}/protocol/openid-connect/logout`;
|
|
61
|
+
const authorizeUrl = `${issuer}/protocol/openid-connect/auth`;
|
|
62
|
+
const redirectUri = `${cfg.publicOrigin}/api/auth/callback`;
|
|
63
|
+
function scope() {
|
|
64
|
+
const base = "openid profile email organization";
|
|
65
|
+
return cfg.orgAlias ? `${base}:${cfg.orgAlias}` : base;
|
|
66
|
+
}
|
|
67
|
+
function buildAuthorizeUrl(args) {
|
|
68
|
+
const params = new URLSearchParams({
|
|
69
|
+
response_type: "code",
|
|
70
|
+
client_id: cfg.clientId,
|
|
71
|
+
redirect_uri: redirectUri,
|
|
72
|
+
scope: scope(),
|
|
73
|
+
state: args.state,
|
|
74
|
+
code_challenge: args.codeChallenge,
|
|
75
|
+
code_challenge_method: "S256"
|
|
76
|
+
});
|
|
77
|
+
if (args.nonce) params.set("nonce", args.nonce);
|
|
78
|
+
return `${authorizeUrl}?${params.toString().replace(/\+/g, "%20")}`;
|
|
79
|
+
}
|
|
80
|
+
async function postToken(body) {
|
|
81
|
+
body.set("client_id", cfg.clientId);
|
|
82
|
+
body.set("client_secret", cfg.clientSecret);
|
|
83
|
+
const res = await fetch(tokenUrl, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
86
|
+
body
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) throw new Error(`KC token endpoint ${res.status}: ${await res.text()}`);
|
|
89
|
+
const j = await res.json();
|
|
90
|
+
return {
|
|
91
|
+
accessToken: j.access_token,
|
|
92
|
+
refreshToken: j.refresh_token,
|
|
93
|
+
idToken: j.id_token,
|
|
94
|
+
expiresIn: j.expires_in,
|
|
95
|
+
refreshExpiresIn: j.refresh_expires_in
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function exchangeCode(args) {
|
|
99
|
+
const body = new URLSearchParams({
|
|
100
|
+
grant_type: "authorization_code",
|
|
101
|
+
code: args.code,
|
|
102
|
+
redirect_uri: redirectUri,
|
|
103
|
+
code_verifier: args.codeVerifier
|
|
104
|
+
});
|
|
105
|
+
return postToken(body);
|
|
106
|
+
}
|
|
107
|
+
async function refresh(refreshToken) {
|
|
108
|
+
const body = new URLSearchParams({
|
|
109
|
+
grant_type: "refresh_token",
|
|
110
|
+
refresh_token: refreshToken
|
|
111
|
+
});
|
|
112
|
+
return postToken(body);
|
|
113
|
+
}
|
|
114
|
+
async function logout(refreshToken) {
|
|
115
|
+
const body = new URLSearchParams({
|
|
116
|
+
client_id: cfg.clientId,
|
|
117
|
+
client_secret: cfg.clientSecret,
|
|
118
|
+
refresh_token: refreshToken
|
|
119
|
+
});
|
|
120
|
+
const res = await fetch(logoutUrl, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
123
|
+
body
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok && res.status !== 204) {
|
|
126
|
+
throw new Error(`KC logout ${res.status}: ${await res.text()}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { buildAuthorizeUrl, exchangeCode, refresh, logout, redirectUri, scope };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/bff/auth/refreshStore.ts
|
|
133
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
134
|
+
|
|
135
|
+
// src/bff/auth/schema.ts
|
|
136
|
+
import {
|
|
137
|
+
customType,
|
|
138
|
+
index,
|
|
139
|
+
pgSchema,
|
|
140
|
+
text,
|
|
141
|
+
timestamp,
|
|
142
|
+
uuid,
|
|
143
|
+
varchar
|
|
144
|
+
} from "drizzle-orm/pg-core";
|
|
145
|
+
var authSchema = pgSchema("auth");
|
|
146
|
+
var bytea = customType({
|
|
147
|
+
dataType: () => "bytea",
|
|
148
|
+
toDriver: (v) => Buffer.from(v),
|
|
149
|
+
fromDriver: (v) => new Uint8Array(v)
|
|
150
|
+
});
|
|
151
|
+
var sessions = authSchema.table(
|
|
152
|
+
"sessions",
|
|
153
|
+
{
|
|
154
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
155
|
+
userSub: varchar("user_sub", { length: 64 }).notNull(),
|
|
156
|
+
userEmail: varchar("user_email", { length: 320 }),
|
|
157
|
+
organizationId: varchar("organization_id", { length: 64 }),
|
|
158
|
+
organizationAlias: varchar("organization_alias", { length: 255 }),
|
|
159
|
+
accessToken: text("access_token").notNull(),
|
|
160
|
+
accessExpiresAt: timestamp("access_expires_at", { withTimezone: true }).notNull(),
|
|
161
|
+
refreshCt: bytea("refresh_ct").notNull(),
|
|
162
|
+
refreshIv: bytea("refresh_iv").notNull(),
|
|
163
|
+
refreshExpiresAt: timestamp("refresh_expires_at", { withTimezone: true }).notNull(),
|
|
164
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
165
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
166
|
+
revokedAt: timestamp("revoked_at", { withTimezone: true })
|
|
167
|
+
},
|
|
168
|
+
(t) => ({
|
|
169
|
+
userSubIdx: index("ix_auth_sessions_user_sub").on(t.userSub)
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
var loginState = authSchema.table("login_state", {
|
|
173
|
+
state: varchar("state", { length: 128 }).primaryKey(),
|
|
174
|
+
codeVerifier: varchar("code_verifier", { length: 255 }).notNull(),
|
|
175
|
+
redirectPath: text("redirect_path").notNull().default("/"),
|
|
176
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
177
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// src/bff/auth/refreshStore.ts
|
|
181
|
+
function createRefreshStore(opts) {
|
|
182
|
+
const { db, cipher } = opts;
|
|
183
|
+
async function createSession(input) {
|
|
184
|
+
const { ciphertext, iv } = await encrypt(cipher, input.refreshToken);
|
|
185
|
+
const [row] = await db.insert(sessions).values({
|
|
186
|
+
userSub: input.userSub,
|
|
187
|
+
userEmail: input.userEmail,
|
|
188
|
+
organizationId: input.organizationId,
|
|
189
|
+
organizationAlias: input.organizationAlias,
|
|
190
|
+
accessToken: input.accessToken,
|
|
191
|
+
accessExpiresAt: input.accessExpiresAt,
|
|
192
|
+
refreshCt: ciphertext,
|
|
193
|
+
refreshIv: iv,
|
|
194
|
+
refreshExpiresAt: input.refreshExpiresAt
|
|
195
|
+
}).returning({ id: sessions.id });
|
|
196
|
+
return row.id;
|
|
197
|
+
}
|
|
198
|
+
async function getSession(id) {
|
|
199
|
+
const [row] = await db.select().from(sessions).where(and(eq(sessions.id, id), isNull(sessions.revokedAt))).limit(1);
|
|
200
|
+
if (!row) return null;
|
|
201
|
+
const refreshToken = await decrypt(cipher, row.refreshCt, row.refreshIv);
|
|
202
|
+
return {
|
|
203
|
+
id: row.id,
|
|
204
|
+
userSub: row.userSub,
|
|
205
|
+
userEmail: row.userEmail,
|
|
206
|
+
organizationId: row.organizationId,
|
|
207
|
+
organizationAlias: row.organizationAlias,
|
|
208
|
+
accessToken: row.accessToken,
|
|
209
|
+
accessExpiresAt: row.accessExpiresAt,
|
|
210
|
+
refreshToken,
|
|
211
|
+
refreshExpiresAt: row.refreshExpiresAt
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function rotateTokens(id, t) {
|
|
215
|
+
const { ciphertext, iv } = await encrypt(cipher, t.refreshToken);
|
|
216
|
+
await db.update(sessions).set({
|
|
217
|
+
accessToken: t.accessToken,
|
|
218
|
+
accessExpiresAt: t.accessExpiresAt,
|
|
219
|
+
refreshCt: ciphertext,
|
|
220
|
+
refreshIv: iv,
|
|
221
|
+
refreshExpiresAt: t.refreshExpiresAt,
|
|
222
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
223
|
+
}).where(eq(sessions.id, id));
|
|
224
|
+
}
|
|
225
|
+
async function revokeSession(id) {
|
|
226
|
+
await db.update(sessions).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id));
|
|
227
|
+
}
|
|
228
|
+
async function revokeByUser(userSub) {
|
|
229
|
+
await db.update(sessions).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and(eq(sessions.userSub, userSub), isNull(sessions.revokedAt)));
|
|
230
|
+
}
|
|
231
|
+
return { createSession, getSession, rotateTokens, revokeSession, revokeByUser };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/bff/auth/session.ts
|
|
235
|
+
import { webcrypto as webcrypto2 } from "crypto";
|
|
236
|
+
var COOKIE_NAME = "qdc_session";
|
|
237
|
+
var MAX_AGE = 60 * 60 * 24 * 30;
|
|
238
|
+
var SUBTLE2 = webcrypto2.subtle;
|
|
239
|
+
function b64url(bytes) {
|
|
240
|
+
return Buffer.from(bytes).toString("base64url");
|
|
241
|
+
}
|
|
242
|
+
function b64urlDecode(s) {
|
|
243
|
+
return new Uint8Array(Buffer.from(s, "base64url"));
|
|
244
|
+
}
|
|
245
|
+
async function hmacKey(hexSecret) {
|
|
246
|
+
const raw = Buffer.from(hexSecret, "hex");
|
|
247
|
+
return SUBTLE2.importKey(
|
|
248
|
+
"raw",
|
|
249
|
+
raw,
|
|
250
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
251
|
+
false,
|
|
252
|
+
["sign", "verify"]
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
function sessionCookie(opts) {
|
|
256
|
+
if (opts.secret.length < 32) throw new Error("session cookie secret too short");
|
|
257
|
+
async function sign(id) {
|
|
258
|
+
const key = await hmacKey(opts.secret);
|
|
259
|
+
const mac = await SUBTLE2.sign("HMAC", key, new TextEncoder().encode(id));
|
|
260
|
+
return `${b64url(new TextEncoder().encode(id))}.${b64url(new Uint8Array(mac))}`;
|
|
261
|
+
}
|
|
262
|
+
async function verify(value) {
|
|
263
|
+
const parts = value.split(".");
|
|
264
|
+
if (parts.length !== 2) return null;
|
|
265
|
+
const [encId, encMac] = parts;
|
|
266
|
+
let idBytes;
|
|
267
|
+
let macBytes;
|
|
268
|
+
try {
|
|
269
|
+
idBytes = b64urlDecode(encId);
|
|
270
|
+
macBytes = b64urlDecode(encMac);
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const key = await hmacKey(opts.secret);
|
|
275
|
+
const ok = await SUBTLE2.verify("HMAC", key, macBytes, idBytes);
|
|
276
|
+
return ok ? new TextDecoder().decode(idBytes) : null;
|
|
277
|
+
}
|
|
278
|
+
async function issue(id) {
|
|
279
|
+
const signed = await sign(id);
|
|
280
|
+
return `${COOKIE_NAME}=${signed}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${MAX_AGE}`;
|
|
281
|
+
}
|
|
282
|
+
function clear() {
|
|
283
|
+
return `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
|
284
|
+
}
|
|
285
|
+
async function read(cookieHeader) {
|
|
286
|
+
if (!cookieHeader) return null;
|
|
287
|
+
for (const part of cookieHeader.split(";")) {
|
|
288
|
+
const trimmed = part.trim();
|
|
289
|
+
if (!trimmed.startsWith(`${COOKIE_NAME}=`)) continue;
|
|
290
|
+
const value = trimmed.slice(COOKIE_NAME.length + 1);
|
|
291
|
+
return verify(value);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return { issue, clear, read };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/bff/auth/handlers/login.ts
|
|
299
|
+
import { webcrypto as webcrypto3 } from "crypto";
|
|
300
|
+
function randomB64Url(bytes) {
|
|
301
|
+
const buf = new Uint8Array(bytes);
|
|
302
|
+
webcrypto3.getRandomValues(buf);
|
|
303
|
+
return Buffer.from(buf).toString("base64url");
|
|
304
|
+
}
|
|
305
|
+
async function sha256B64Url(input) {
|
|
306
|
+
const data = new TextEncoder().encode(input);
|
|
307
|
+
const digest = await webcrypto3.subtle.digest("SHA-256", data);
|
|
308
|
+
return Buffer.from(new Uint8Array(digest)).toString("base64url");
|
|
309
|
+
}
|
|
310
|
+
function sanitizeRedirect(raw) {
|
|
311
|
+
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/";
|
|
312
|
+
return raw;
|
|
313
|
+
}
|
|
314
|
+
function mountLogin(app, opts) {
|
|
315
|
+
app.get("/api/auth/login", async (c) => {
|
|
316
|
+
const redirect = sanitizeRedirect(c.req.query("redirect"));
|
|
317
|
+
const state = randomB64Url(32);
|
|
318
|
+
const codeVerifier = randomB64Url(48);
|
|
319
|
+
const codeChallenge = await sha256B64Url(codeVerifier);
|
|
320
|
+
await opts.db.insert(loginState).values({
|
|
321
|
+
state,
|
|
322
|
+
codeVerifier,
|
|
323
|
+
redirectPath: redirect,
|
|
324
|
+
expiresAt: new Date(Date.now() + 10 * 6e4)
|
|
325
|
+
});
|
|
326
|
+
const url = opts.kcClient.buildAuthorizeUrl({ state, codeChallenge });
|
|
327
|
+
return c.redirect(url, 302);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/bff/auth/handlers/callback.ts
|
|
332
|
+
import { eq as eq2 } from "drizzle-orm";
|
|
333
|
+
import { decodeJwt } from "jose";
|
|
334
|
+
function mountCallback(app, opts) {
|
|
335
|
+
app.get("/api/auth/callback", async (c) => {
|
|
336
|
+
const code = c.req.query("code");
|
|
337
|
+
const state = c.req.query("state");
|
|
338
|
+
if (!code || !state) return c.text("missing code or state", 400);
|
|
339
|
+
const [row] = await opts.db.select().from(loginState).where(eq2(loginState.state, state)).limit(1);
|
|
340
|
+
if (!row) return c.text("invalid state", 400);
|
|
341
|
+
if (row.expiresAt < /* @__PURE__ */ new Date()) {
|
|
342
|
+
await opts.db.delete(loginState).where(eq2(loginState.state, state));
|
|
343
|
+
return c.text("expired state", 400);
|
|
344
|
+
}
|
|
345
|
+
const tokens = await opts.kcClient.exchangeCode({
|
|
346
|
+
code,
|
|
347
|
+
codeVerifier: row.codeVerifier
|
|
348
|
+
});
|
|
349
|
+
await opts.db.delete(loginState).where(eq2(loginState.state, state));
|
|
350
|
+
const claims = decodeJwt(tokens.accessToken);
|
|
351
|
+
const sessionId = await opts.refreshStore.createSession({
|
|
352
|
+
userSub: claims.sub,
|
|
353
|
+
userEmail: claims.email,
|
|
354
|
+
organizationId: claims.organization?.id,
|
|
355
|
+
organizationAlias: claims.organization?.alias,
|
|
356
|
+
accessToken: tokens.accessToken,
|
|
357
|
+
accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1e3),
|
|
358
|
+
refreshToken: tokens.refreshToken,
|
|
359
|
+
refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1e3)
|
|
360
|
+
});
|
|
361
|
+
c.header("Set-Cookie", await opts.cookies.issue(sessionId));
|
|
362
|
+
return c.redirect(row.redirectPath, 302);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/bff/auth/handlers/me.ts
|
|
367
|
+
function mountMe(app, opts) {
|
|
368
|
+
app.get("/api/auth/me", async (c) => {
|
|
369
|
+
const id = await opts.cookies.read(c.req.header("cookie"));
|
|
370
|
+
if (!id) return c.json({ error: "unauthenticated" }, 401);
|
|
371
|
+
const session = await opts.refreshStore.getSession(id);
|
|
372
|
+
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
|
373
|
+
return c.json({
|
|
374
|
+
user: {
|
|
375
|
+
id: session.userSub,
|
|
376
|
+
email: session.userEmail
|
|
377
|
+
},
|
|
378
|
+
organization: {
|
|
379
|
+
id: session.organizationId,
|
|
380
|
+
alias: session.organizationAlias
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/bff/auth/handlers/refresh.ts
|
|
387
|
+
function mountRefresh(app, opts) {
|
|
388
|
+
app.post("/api/auth/refresh", async (c) => {
|
|
389
|
+
const id = await opts.cookies.read(c.req.header("cookie"));
|
|
390
|
+
if (!id) return c.json({ error: "unauthenticated" }, 401);
|
|
391
|
+
const session = await opts.refreshStore.getSession(id);
|
|
392
|
+
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
|
393
|
+
const tokens = await opts.kcClient.refresh(session.refreshToken);
|
|
394
|
+
await opts.refreshStore.rotateTokens(id, {
|
|
395
|
+
accessToken: tokens.accessToken,
|
|
396
|
+
accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1e3),
|
|
397
|
+
refreshToken: tokens.refreshToken,
|
|
398
|
+
refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1e3)
|
|
399
|
+
});
|
|
400
|
+
return c.body(null, 204);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/bff/auth/handlers/logout.ts
|
|
405
|
+
function mountLogout(app, opts) {
|
|
406
|
+
app.post("/api/auth/logout", async (c) => {
|
|
407
|
+
const id = await opts.cookies.read(c.req.header("cookie"));
|
|
408
|
+
if (id) {
|
|
409
|
+
const session = await opts.refreshStore.getSession(id);
|
|
410
|
+
if (session) {
|
|
411
|
+
try {
|
|
412
|
+
await opts.kcClient.logout(session.refreshToken);
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
415
|
+
await opts.refreshStore.revokeSession(id);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
c.header("Set-Cookie", opts.cookies.clear());
|
|
419
|
+
return c.body(null, 204);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/bff/auth/handlers/backchannelLogout.ts
|
|
424
|
+
var EVENT_KEY = "http://schemas.openid.net/event/backchannel-logout";
|
|
425
|
+
function mountBackchannelLogout(app, opts) {
|
|
426
|
+
app.post("/api/auth/backchannel-logout", async (c) => {
|
|
427
|
+
const form = await c.req.parseBody();
|
|
428
|
+
const logoutToken = form["logout_token"];
|
|
429
|
+
if (typeof logoutToken !== "string") return c.text("missing logout_token", 400);
|
|
430
|
+
let claims;
|
|
431
|
+
try {
|
|
432
|
+
claims = await opts.jwks.verify(logoutToken);
|
|
433
|
+
} catch {
|
|
434
|
+
return c.text("invalid logout_token", 400);
|
|
435
|
+
}
|
|
436
|
+
if (!claims.events || !(EVENT_KEY in claims.events)) {
|
|
437
|
+
return c.text("invalid logout_token events", 400);
|
|
438
|
+
}
|
|
439
|
+
if (!claims.sub) return c.text("missing sub", 400);
|
|
440
|
+
await opts.refreshStore.revokeByUser(claims.sub);
|
|
441
|
+
return c.text("ok", 200);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/bff/auth/requireAuth.ts
|
|
446
|
+
function requireAuth(opts) {
|
|
447
|
+
const skewSec = opts.refreshSkewSec ?? 30;
|
|
448
|
+
return async (c, next) => {
|
|
449
|
+
const id = await opts.cookies.read(c.req.header("cookie"));
|
|
450
|
+
if (!id) return c.json({ error: "unauthenticated" }, 401);
|
|
451
|
+
let session = await opts.refreshStore.getSession(id);
|
|
452
|
+
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
|
453
|
+
const expiresInMs = session.accessExpiresAt.getTime() - Date.now();
|
|
454
|
+
if (expiresInMs < skewSec * 1e3) {
|
|
455
|
+
try {
|
|
456
|
+
const tokens = await opts.kcClient.refresh(session.refreshToken);
|
|
457
|
+
await opts.refreshStore.rotateTokens(id, {
|
|
458
|
+
accessToken: tokens.accessToken,
|
|
459
|
+
accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1e3),
|
|
460
|
+
refreshToken: tokens.refreshToken,
|
|
461
|
+
refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1e3)
|
|
462
|
+
});
|
|
463
|
+
session = await opts.refreshStore.getSession(id);
|
|
464
|
+
} catch {
|
|
465
|
+
return c.json({ error: "session_expired" }, 401);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
c.set("session", session);
|
|
469
|
+
c.set("user", {
|
|
470
|
+
id: session.userSub,
|
|
471
|
+
email: session.userEmail,
|
|
472
|
+
organization: { id: session.organizationId, alias: session.organizationAlias }
|
|
473
|
+
});
|
|
474
|
+
await next();
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/bff/auth/attachAuth.ts
|
|
479
|
+
async function attachAuth(app, opts) {
|
|
480
|
+
const cipher = await createCipher(opts.sessionCookieSecret);
|
|
481
|
+
const cookies = sessionCookie({ secret: opts.sessionCookieSecret });
|
|
482
|
+
const refreshStore = createRefreshStore({ db: opts.db, cipher });
|
|
483
|
+
const kcClient = createKcClient({
|
|
484
|
+
keycloakUrl: opts.keycloakUrl,
|
|
485
|
+
realm: opts.realm,
|
|
486
|
+
clientId: opts.clientId,
|
|
487
|
+
clientSecret: opts.clientSecret,
|
|
488
|
+
publicOrigin: opts.publicOrigin,
|
|
489
|
+
orgAlias: opts.orgAlias
|
|
490
|
+
});
|
|
491
|
+
const jwks = createJwks({
|
|
492
|
+
keycloakUrl: opts.keycloakUrl,
|
|
493
|
+
realm: opts.realm,
|
|
494
|
+
audience: opts.clientId
|
|
495
|
+
});
|
|
496
|
+
mountLogin(app, { kcClient, db: opts.db });
|
|
497
|
+
mountCallback(app, { kcClient, db: opts.db, refreshStore, cookies });
|
|
498
|
+
mountMe(app, { refreshStore, cookies });
|
|
499
|
+
mountRefresh(app, { kcClient, refreshStore, cookies });
|
|
500
|
+
mountLogout(app, { kcClient, refreshStore, cookies });
|
|
501
|
+
mountBackchannelLogout(app, { jwks, refreshStore });
|
|
502
|
+
return {
|
|
503
|
+
requireAuth: requireAuth({ cookies, refreshStore, kcClient })
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/bff/auth/migrate.ts
|
|
508
|
+
import { readFile } from "fs/promises";
|
|
509
|
+
import { fileURLToPath } from "url";
|
|
510
|
+
import { dirname, join } from "path";
|
|
511
|
+
import { sql } from "drizzle-orm";
|
|
512
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
513
|
+
var MIGRATIONS = ["0001_init.sql"];
|
|
514
|
+
async function runAuthMigrations(db) {
|
|
515
|
+
for (const file of MIGRATIONS) {
|
|
516
|
+
const text2 = await readFile(join(here, "migrations", file), "utf8");
|
|
517
|
+
await db.execute(sql.raw(text2));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
export {
|
|
521
|
+
attachAuth,
|
|
522
|
+
requireAuth,
|
|
523
|
+
runAuthMigrations
|
|
524
|
+
};
|
|
525
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/bff/auth/crypto.ts","../../../src/bff/auth/jwks.ts","../../../src/bff/auth/kcClient.ts","../../../src/bff/auth/refreshStore.ts","../../../src/bff/auth/schema.ts","../../../src/bff/auth/session.ts","../../../src/bff/auth/handlers/login.ts","../../../src/bff/auth/handlers/callback.ts","../../../src/bff/auth/handlers/me.ts","../../../src/bff/auth/handlers/refresh.ts","../../../src/bff/auth/handlers/logout.ts","../../../src/bff/auth/handlers/backchannelLogout.ts","../../../src/bff/auth/requireAuth.ts","../../../src/bff/auth/attachAuth.ts","../../../src/bff/auth/migrate.ts"],"sourcesContent":["import { webcrypto } from \"node:crypto\"\n\nconst SUBTLE = webcrypto.subtle\n\nexport type Cipher = { key: CryptoKey }\n\nexport async function createCipher(hexSecret: string): Promise<Cipher> {\n if (hexSecret.length < 32) {\n throw new Error(\"SESSION_COOKIE_SECRET must be at least 32 hex chars\")\n }\n const raw = Buffer.from(hexSecret, \"hex\")\n const baseKey = await SUBTLE.importKey(\"raw\", raw, \"HKDF\", false, [\"deriveKey\"])\n const key = await SUBTLE.deriveKey(\n {\n name: \"HKDF\",\n hash: \"SHA-256\",\n salt: new Uint8Array(16),\n info: new TextEncoder().encode(\"qdc-sdk:refresh-token:v1\"),\n },\n baseKey,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"],\n )\n return { key }\n}\n\nexport async function encrypt(cipher: Cipher, plaintext: string) {\n const iv = webcrypto.getRandomValues(new Uint8Array(12))\n const enc = await SUBTLE.encrypt(\n { name: \"AES-GCM\", iv },\n cipher.key,\n new TextEncoder().encode(plaintext),\n )\n return { ciphertext: new Uint8Array(enc), iv }\n}\n\nexport async function decrypt(\n cipher: Cipher,\n ciphertext: Uint8Array,\n iv: Uint8Array,\n): Promise<string> {\n const dec = await SUBTLE.decrypt({ name: \"AES-GCM\", iv }, cipher.key, ciphertext)\n return new TextDecoder().decode(dec)\n}\n","import { createRemoteJWKSet, jwtVerify, type JWTPayload } from \"jose\"\n\nexport type JwksOptions = {\n keycloakUrl: string\n realm: string\n audience: string\n cooldownMs?: number\n}\n\nexport type Jwks = {\n verify: (token: string) => Promise<JWTPayload>\n}\n\nexport function createJwks(opts: JwksOptions): Jwks {\n const url = new URL(`${opts.keycloakUrl}/realms/${opts.realm}/protocol/openid-connect/certs`)\n const jwkset = createRemoteJWKSet(url, {\n cooldownDuration: opts.cooldownMs ?? 30_000,\n })\n const issuer = `${opts.keycloakUrl}/realms/${opts.realm}`\n\n async function verify(token: string): Promise<JWTPayload> {\n const { payload } = await jwtVerify(token, jwkset, {\n audience: opts.audience,\n issuer,\n })\n return payload\n }\n\n return { verify }\n}\n","export type KcConfig = {\n keycloakUrl: string\n realm: string\n clientId: string\n clientSecret: string\n publicOrigin: string\n orgAlias?: string\n}\n\nexport type KcTokens = {\n accessToken: string\n refreshToken: string\n idToken: string\n expiresIn: number\n refreshExpiresIn: number\n}\n\nexport type KcClient = ReturnType<typeof createKcClient>\n\nexport function createKcClient(cfg: KcConfig) {\n const issuer = `${cfg.keycloakUrl}/realms/${cfg.realm}`\n const tokenUrl = `${issuer}/protocol/openid-connect/token`\n const logoutUrl = `${issuer}/protocol/openid-connect/logout`\n const authorizeUrl = `${issuer}/protocol/openid-connect/auth`\n const redirectUri = `${cfg.publicOrigin}/api/auth/callback`\n\n function scope(): string {\n const base = \"openid profile email organization\"\n return cfg.orgAlias ? `${base}:${cfg.orgAlias}` : base\n }\n\n function buildAuthorizeUrl(args: { state: string; codeChallenge: string; nonce?: string }) {\n const params = new URLSearchParams({\n response_type: \"code\",\n client_id: cfg.clientId,\n redirect_uri: redirectUri,\n scope: scope(),\n state: args.state,\n code_challenge: args.codeChallenge,\n code_challenge_method: \"S256\",\n })\n if (args.nonce) params.set(\"nonce\", args.nonce)\n return `${authorizeUrl}?${params.toString().replace(/\\+/g, \"%20\")}`\n }\n\n async function postToken(body: URLSearchParams): Promise<KcTokens> {\n body.set(\"client_id\", cfg.clientId)\n body.set(\"client_secret\", cfg.clientSecret)\n const res = await fetch(tokenUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body,\n })\n if (!res.ok) throw new Error(`KC token endpoint ${res.status}: ${await res.text()}`)\n const j = (await res.json()) as {\n access_token: string\n refresh_token: string\n id_token: string\n expires_in: number\n refresh_expires_in: number\n }\n return {\n accessToken: j.access_token,\n refreshToken: j.refresh_token,\n idToken: j.id_token,\n expiresIn: j.expires_in,\n refreshExpiresIn: j.refresh_expires_in,\n }\n }\n\n async function exchangeCode(args: { code: string; codeVerifier: string }) {\n const body = new URLSearchParams({\n grant_type: \"authorization_code\",\n code: args.code,\n redirect_uri: redirectUri,\n code_verifier: args.codeVerifier,\n })\n return postToken(body)\n }\n\n async function refresh(refreshToken: string) {\n const body = new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n })\n return postToken(body)\n }\n\n async function logout(refreshToken: string): Promise<void> {\n const body = new URLSearchParams({\n client_id: cfg.clientId,\n client_secret: cfg.clientSecret,\n refresh_token: refreshToken,\n })\n const res = await fetch(logoutUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body,\n })\n if (!res.ok && res.status !== 204) {\n throw new Error(`KC logout ${res.status}: ${await res.text()}`)\n }\n }\n\n return { buildAuthorizeUrl, exchangeCode, refresh, logout, redirectUri, scope }\n}\n","import { and, eq, isNull } from \"drizzle-orm\"\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\"\nimport { sessions } from \"./schema\"\nimport { decrypt, encrypt, type Cipher } from \"./crypto\"\n\nexport type CreateSessionInput = {\n userSub: string\n userEmail?: string\n organizationId?: string\n organizationAlias?: string\n accessToken: string\n accessExpiresAt: Date\n refreshToken: string\n refreshExpiresAt: Date\n}\n\nexport type Session = {\n id: string\n userSub: string\n userEmail: string | null\n organizationId: string | null\n organizationAlias: string | null\n accessToken: string\n accessExpiresAt: Date\n refreshToken: string\n refreshExpiresAt: Date\n}\n\nexport type RefreshStore = ReturnType<typeof createRefreshStore>\n\nexport function createRefreshStore(opts: {\n db: NodePgDatabase\n cipher: Cipher\n}) {\n const { db, cipher } = opts\n\n async function createSession(input: CreateSessionInput): Promise<string> {\n const { ciphertext, iv } = await encrypt(cipher, input.refreshToken)\n const [row] = await db\n .insert(sessions)\n .values({\n userSub: input.userSub,\n userEmail: input.userEmail,\n organizationId: input.organizationId,\n organizationAlias: input.organizationAlias,\n accessToken: input.accessToken,\n accessExpiresAt: input.accessExpiresAt,\n refreshCt: ciphertext,\n refreshIv: iv,\n refreshExpiresAt: input.refreshExpiresAt,\n })\n .returning({ id: sessions.id })\n return row!.id\n }\n\n async function getSession(id: string): Promise<Session | null> {\n const [row] = await db\n .select()\n .from(sessions)\n .where(and(eq(sessions.id, id), isNull(sessions.revokedAt)))\n .limit(1)\n if (!row) return null\n const refreshToken = await decrypt(cipher, row.refreshCt, row.refreshIv)\n return {\n id: row.id,\n userSub: row.userSub,\n userEmail: row.userEmail,\n organizationId: row.organizationId,\n organizationAlias: row.organizationAlias,\n accessToken: row.accessToken,\n accessExpiresAt: row.accessExpiresAt,\n refreshToken,\n refreshExpiresAt: row.refreshExpiresAt,\n }\n }\n\n async function rotateTokens(id: string, t: {\n accessToken: string\n accessExpiresAt: Date\n refreshToken: string\n refreshExpiresAt: Date\n }): Promise<void> {\n const { ciphertext, iv } = await encrypt(cipher, t.refreshToken)\n await db\n .update(sessions)\n .set({\n accessToken: t.accessToken,\n accessExpiresAt: t.accessExpiresAt,\n refreshCt: ciphertext,\n refreshIv: iv,\n refreshExpiresAt: t.refreshExpiresAt,\n updatedAt: new Date(),\n })\n .where(eq(sessions.id, id))\n }\n\n async function revokeSession(id: string): Promise<void> {\n await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, id))\n }\n\n async function revokeByUser(userSub: string): Promise<void> {\n await db\n .update(sessions)\n .set({ revokedAt: new Date() })\n .where(and(eq(sessions.userSub, userSub), isNull(sessions.revokedAt)))\n }\n\n return { createSession, getSession, rotateTokens, revokeSession, revokeByUser }\n}\n","import {\n customType,\n index,\n pgSchema,\n text,\n timestamp,\n uuid,\n varchar,\n} from \"drizzle-orm/pg-core\"\n\nexport const authSchema = pgSchema(\"auth\")\n\nconst bytea = customType<{ data: Uint8Array; driverData: Buffer }>({\n dataType: () => \"bytea\",\n toDriver: (v) => Buffer.from(v),\n fromDriver: (v) => new Uint8Array(v),\n})\n\nexport const sessions = authSchema.table(\n \"sessions\",\n {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n userSub: varchar(\"user_sub\", { length: 64 }).notNull(),\n userEmail: varchar(\"user_email\", { length: 320 }),\n organizationId: varchar(\"organization_id\", { length: 64 }),\n organizationAlias: varchar(\"organization_alias\", { length: 255 }),\n accessToken: text(\"access_token\").notNull(),\n accessExpiresAt: timestamp(\"access_expires_at\", { withTimezone: true }).notNull(),\n refreshCt: bytea(\"refresh_ct\").notNull(),\n refreshIv: bytea(\"refresh_iv\").notNull(),\n refreshExpiresAt: timestamp(\"refresh_expires_at\", { withTimezone: true }).notNull(),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).notNull().defaultNow(),\n updatedAt: timestamp(\"updated_at\", { withTimezone: true }).notNull().defaultNow(),\n revokedAt: timestamp(\"revoked_at\", { withTimezone: true }),\n },\n (t) => ({\n userSubIdx: index(\"ix_auth_sessions_user_sub\").on(t.userSub),\n }),\n)\n\nexport const loginState = authSchema.table(\"login_state\", {\n state: varchar(\"state\", { length: 128 }).primaryKey(),\n codeVerifier: varchar(\"code_verifier\", { length: 255 }).notNull(),\n redirectPath: text(\"redirect_path\").notNull().default(\"/\"),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).notNull().defaultNow(),\n expiresAt: timestamp(\"expires_at\", { withTimezone: true }).notNull(),\n})\n","import { webcrypto } from \"node:crypto\"\n\nexport const COOKIE_NAME = \"qdc_session\"\nconst MAX_AGE = 60 * 60 * 24 * 30 // 30 days\n\nconst SUBTLE = webcrypto.subtle\n\nfunction b64url(bytes: Uint8Array): string {\n return Buffer.from(bytes).toString(\"base64url\")\n}\nfunction b64urlDecode(s: string): Uint8Array {\n return new Uint8Array(Buffer.from(s, \"base64url\"))\n}\n\nasync function hmacKey(hexSecret: string): Promise<CryptoKey> {\n const raw = Buffer.from(hexSecret, \"hex\")\n return SUBTLE.importKey(\n \"raw\",\n raw,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\", \"verify\"],\n )\n}\n\nexport function sessionCookie(opts: { secret: string }) {\n if (opts.secret.length < 32) throw new Error(\"session cookie secret too short\")\n\n async function sign(id: string): Promise<string> {\n const key = await hmacKey(opts.secret)\n const mac = await SUBTLE.sign(\"HMAC\", key, new TextEncoder().encode(id))\n return `${b64url(new TextEncoder().encode(id))}.${b64url(new Uint8Array(mac))}`\n }\n\n async function verify(value: string): Promise<string | null> {\n const parts = value.split(\".\")\n if (parts.length !== 2) return null\n const [encId, encMac] = parts as [string, string]\n let idBytes: Uint8Array\n let macBytes: Uint8Array\n try {\n idBytes = b64urlDecode(encId)\n macBytes = b64urlDecode(encMac)\n } catch { return null }\n const key = await hmacKey(opts.secret)\n const ok = await SUBTLE.verify(\"HMAC\", key, macBytes, idBytes)\n return ok ? new TextDecoder().decode(idBytes) : null\n }\n\n async function issue(id: string): Promise<string> {\n const signed = await sign(id)\n return `${COOKIE_NAME}=${signed}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${MAX_AGE}`\n }\n\n function clear(): string {\n return `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`\n }\n\n async function read(cookieHeader: string | null | undefined): Promise<string | null> {\n if (!cookieHeader) return null\n for (const part of cookieHeader.split(\";\")) {\n const trimmed = part.trim()\n if (!trimmed.startsWith(`${COOKIE_NAME}=`)) continue\n const value = trimmed.slice(COOKIE_NAME.length + 1)\n return verify(value)\n }\n return null\n }\n\n return { issue, clear, read }\n}\n","import type { Hono } from \"hono\"\nimport { webcrypto } from \"node:crypto\"\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\"\nimport type { KcClient } from \"../kcClient\"\nimport { loginState } from \"../schema\"\n\nfunction randomB64Url(bytes: number): string {\n const buf = new Uint8Array(bytes)\n webcrypto.getRandomValues(buf)\n return Buffer.from(buf).toString(\"base64url\")\n}\n\nasync function sha256B64Url(input: string): Promise<string> {\n const data = new TextEncoder().encode(input)\n const digest = await webcrypto.subtle.digest(\"SHA-256\", data)\n return Buffer.from(new Uint8Array(digest)).toString(\"base64url\")\n}\n\nfunction sanitizeRedirect(raw: string | undefined): string {\n if (!raw || !raw.startsWith(\"/\") || raw.startsWith(\"//\")) return \"/\"\n return raw\n}\n\nexport function mountLogin(\n app: Hono,\n opts: { kcClient: KcClient; db: NodePgDatabase },\n): void {\n app.get(\"/api/auth/login\", async (c) => {\n const redirect = sanitizeRedirect(c.req.query(\"redirect\"))\n const state = randomB64Url(32)\n const codeVerifier = randomB64Url(48)\n const codeChallenge = await sha256B64Url(codeVerifier)\n\n await opts.db.insert(loginState).values({\n state,\n codeVerifier,\n redirectPath: redirect,\n expiresAt: new Date(Date.now() + 10 * 60_000),\n })\n\n const url = opts.kcClient.buildAuthorizeUrl({ state, codeChallenge })\n return c.redirect(url, 302)\n })\n}\n","import type { Hono } from \"hono\"\nimport { eq } from \"drizzle-orm\"\nimport { decodeJwt } from \"jose\"\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\"\nimport type { KcClient } from \"../kcClient\"\nimport type { RefreshStore } from \"../refreshStore\"\nimport { loginState } from \"../schema\"\n\ntype Cookies = {\n issue: (id: string) => Promise<string>\n}\n\ntype Claims = {\n sub: string\n email?: string\n organization?: { id?: string; alias?: string }\n}\n\nexport function mountCallback(\n app: Hono,\n opts: {\n kcClient: KcClient\n db: NodePgDatabase\n refreshStore: RefreshStore\n cookies: Cookies\n },\n): void {\n app.get(\"/api/auth/callback\", async (c) => {\n const code = c.req.query(\"code\")\n const state = c.req.query(\"state\")\n if (!code || !state) return c.text(\"missing code or state\", 400)\n\n const [row] = await opts.db\n .select()\n .from(loginState)\n .where(eq(loginState.state, state))\n .limit(1)\n if (!row) return c.text(\"invalid state\", 400)\n if (row.expiresAt < new Date()) {\n await opts.db.delete(loginState).where(eq(loginState.state, state))\n return c.text(\"expired state\", 400)\n }\n\n const tokens = await opts.kcClient.exchangeCode({\n code,\n codeVerifier: row.codeVerifier,\n })\n await opts.db.delete(loginState).where(eq(loginState.state, state))\n\n const claims = decodeJwt(tokens.accessToken) as unknown as Claims\n const sessionId = await opts.refreshStore.createSession({\n userSub: claims.sub,\n userEmail: claims.email,\n organizationId: claims.organization?.id,\n organizationAlias: claims.organization?.alias,\n accessToken: tokens.accessToken,\n accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000),\n refreshToken: tokens.refreshToken,\n refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1000),\n })\n\n c.header(\"Set-Cookie\", await opts.cookies.issue(sessionId))\n return c.redirect(row.redirectPath, 302)\n })\n}\n","import type { Hono } from \"hono\"\nimport type { RefreshStore } from \"../refreshStore\"\n\ntype Cookies = {\n read: (cookieHeader: string | null | undefined) => Promise<string | null>\n}\n\nexport function mountMe(\n app: Hono,\n opts: { refreshStore: RefreshStore; cookies: Cookies },\n): void {\n app.get(\"/api/auth/me\", async (c) => {\n const id = await opts.cookies.read(c.req.header(\"cookie\"))\n if (!id) return c.json({ error: \"unauthenticated\" }, 401)\n const session = await opts.refreshStore.getSession(id)\n if (!session) return c.json({ error: \"unauthenticated\" }, 401)\n return c.json({\n user: {\n id: session.userSub,\n email: session.userEmail,\n },\n organization: {\n id: session.organizationId,\n alias: session.organizationAlias,\n },\n })\n })\n}\n","import type { Hono } from \"hono\"\nimport type { KcClient } from \"../kcClient\"\nimport type { RefreshStore } from \"../refreshStore\"\n\ntype Cookies = {\n read: (cookieHeader: string | null | undefined) => Promise<string | null>\n}\n\nexport function mountRefresh(\n app: Hono,\n opts: { kcClient: KcClient; refreshStore: RefreshStore; cookies: Cookies },\n): void {\n app.post(\"/api/auth/refresh\", async (c) => {\n const id = await opts.cookies.read(c.req.header(\"cookie\"))\n if (!id) return c.json({ error: \"unauthenticated\" }, 401)\n const session = await opts.refreshStore.getSession(id)\n if (!session) return c.json({ error: \"unauthenticated\" }, 401)\n const tokens = await opts.kcClient.refresh(session.refreshToken)\n await opts.refreshStore.rotateTokens(id, {\n accessToken: tokens.accessToken,\n accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000),\n refreshToken: tokens.refreshToken,\n refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1000),\n })\n return c.body(null, 204)\n })\n}\n","import type { Hono } from \"hono\"\nimport type { KcClient } from \"../kcClient\"\nimport type { RefreshStore } from \"../refreshStore\"\n\ntype Cookies = {\n read: (cookieHeader: string | null | undefined) => Promise<string | null>\n clear: () => string\n}\n\nexport function mountLogout(\n app: Hono,\n opts: { kcClient: KcClient; refreshStore: RefreshStore; cookies: Cookies },\n): void {\n app.post(\"/api/auth/logout\", async (c) => {\n const id = await opts.cookies.read(c.req.header(\"cookie\"))\n if (id) {\n const session = await opts.refreshStore.getSession(id)\n if (session) {\n try { await opts.kcClient.logout(session.refreshToken) } catch { /* best-effort */ }\n await opts.refreshStore.revokeSession(id)\n }\n }\n c.header(\"Set-Cookie\", opts.cookies.clear())\n return c.body(null, 204)\n })\n}\n","import type { Hono } from \"hono\"\nimport type { Jwks } from \"../jwks\"\nimport type { RefreshStore } from \"../refreshStore\"\n\nconst EVENT_KEY = \"http://schemas.openid.net/event/backchannel-logout\"\n\nexport function mountBackchannelLogout(\n app: Hono,\n opts: { jwks: Jwks; refreshStore: RefreshStore },\n): void {\n app.post(\"/api/auth/backchannel-logout\", async (c) => {\n const form = await c.req.parseBody()\n const logoutToken = form[\"logout_token\"]\n if (typeof logoutToken !== \"string\") return c.text(\"missing logout_token\", 400)\n\n let claims: { sub?: string; events?: Record<string, unknown> }\n try {\n claims = (await opts.jwks.verify(logoutToken)) as never\n } catch {\n return c.text(\"invalid logout_token\", 400)\n }\n\n if (!claims.events || !(EVENT_KEY in claims.events)) {\n return c.text(\"invalid logout_token events\", 400)\n }\n if (!claims.sub) return c.text(\"missing sub\", 400)\n\n await opts.refreshStore.revokeByUser(claims.sub)\n return c.text(\"ok\", 200)\n })\n}\n","import type { MiddlewareHandler } from \"hono\"\nimport type { KcClient } from \"./kcClient\"\nimport type { RefreshStore, Session } from \"./refreshStore\"\n\ntype Cookies = {\n read: (cookieHeader: string | null | undefined) => Promise<string | null>\n}\n\nexport type AuthUser = {\n id: string\n email: string | null\n organization: { id: string | null; alias: string | null }\n}\n\ndeclare module \"hono\" {\n interface ContextVariableMap {\n user: AuthUser\n session: Session\n }\n}\n\nexport function requireAuth(opts: {\n cookies: Cookies\n refreshStore: RefreshStore\n kcClient: KcClient\n refreshSkewSec?: number\n}): MiddlewareHandler {\n const skewSec = opts.refreshSkewSec ?? 30\n return async (c, next) => {\n const id = await opts.cookies.read(c.req.header(\"cookie\"))\n if (!id) return c.json({ error: \"unauthenticated\" }, 401)\n let session = await opts.refreshStore.getSession(id)\n if (!session) return c.json({ error: \"unauthenticated\" }, 401)\n\n const expiresInMs = session.accessExpiresAt.getTime() - Date.now()\n if (expiresInMs < skewSec * 1000) {\n try {\n const tokens = await opts.kcClient.refresh(session.refreshToken)\n await opts.refreshStore.rotateTokens(id, {\n accessToken: tokens.accessToken,\n accessExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000),\n refreshToken: tokens.refreshToken,\n refreshExpiresAt: new Date(Date.now() + tokens.refreshExpiresIn * 1000),\n })\n session = (await opts.refreshStore.getSession(id))!\n } catch {\n return c.json({ error: \"session_expired\" }, 401)\n }\n }\n\n c.set(\"session\", session)\n c.set(\"user\", {\n id: session.userSub,\n email: session.userEmail,\n organization: { id: session.organizationId, alias: session.organizationAlias },\n })\n await next()\n }\n}\n","import type { Hono } from \"hono\"\nimport { createCipher } from \"./crypto\"\nimport { createJwks } from \"./jwks\"\nimport { createKcClient } from \"./kcClient\"\nimport { createRefreshStore } from \"./refreshStore\"\nimport { sessionCookie } from \"./session\"\nimport { mountLogin } from \"./handlers/login\"\nimport { mountCallback } from \"./handlers/callback\"\nimport { mountMe } from \"./handlers/me\"\nimport { mountRefresh } from \"./handlers/refresh\"\nimport { mountLogout } from \"./handlers/logout\"\nimport { mountBackchannelLogout } from \"./handlers/backchannelLogout\"\nimport { requireAuth } from \"./requireAuth\"\nimport type { AttachAuthOptions } from \"./types\"\n\nexport type AttachedAuth = {\n requireAuth: ReturnType<typeof requireAuth>\n}\n\nexport async function attachAuth(\n app: Hono,\n opts: AttachAuthOptions,\n): Promise<AttachedAuth> {\n const cipher = await createCipher(opts.sessionCookieSecret)\n const cookies = sessionCookie({ secret: opts.sessionCookieSecret })\n const refreshStore = createRefreshStore({ db: opts.db, cipher })\n const kcClient = createKcClient({\n keycloakUrl: opts.keycloakUrl,\n realm: opts.realm,\n clientId: opts.clientId,\n clientSecret: opts.clientSecret,\n publicOrigin: opts.publicOrigin,\n orgAlias: opts.orgAlias,\n })\n const jwks = createJwks({\n keycloakUrl: opts.keycloakUrl,\n realm: opts.realm,\n audience: opts.clientId,\n })\n\n mountLogin(app, { kcClient, db: opts.db })\n mountCallback(app, { kcClient, db: opts.db, refreshStore, cookies })\n mountMe(app, { refreshStore, cookies })\n mountRefresh(app, { kcClient, refreshStore, cookies })\n mountLogout(app, { kcClient, refreshStore, cookies })\n mountBackchannelLogout(app, { jwks, refreshStore })\n\n return {\n requireAuth: requireAuth({ cookies, refreshStore, kcClient }),\n }\n}\n","import { readFile } from \"node:fs/promises\"\nimport { fileURLToPath } from \"node:url\"\nimport { dirname, join } from \"node:path\"\nimport { sql } from \"drizzle-orm\"\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\"\n\nconst here = dirname(fileURLToPath(import.meta.url))\nconst MIGRATIONS = [\"0001_init.sql\"]\n\nexport async function runAuthMigrations(db: NodePgDatabase): Promise<void> {\n for (const file of MIGRATIONS) {\n const text = await readFile(join(here, \"migrations\", file), \"utf8\")\n await db.execute(sql.raw(text))\n }\n}\n"],"mappings":"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","names":["webcrypto","SUBTLE","webcrypto","webcrypto","eq","eq","text"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS auth;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
4
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
5
|
+
user_sub varchar(64) NOT NULL,
|
|
6
|
+
user_email varchar(320),
|
|
7
|
+
organization_id varchar(64),
|
|
8
|
+
organization_alias varchar(255),
|
|
9
|
+
access_token text NOT NULL,
|
|
10
|
+
access_expires_at timestamptz NOT NULL,
|
|
11
|
+
refresh_ct bytea NOT NULL,
|
|
12
|
+
refresh_iv bytea NOT NULL,
|
|
13
|
+
refresh_expires_at timestamptz NOT NULL,
|
|
14
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
16
|
+
revoked_at timestamptz
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX IF NOT EXISTS ix_auth_sessions_user_sub
|
|
20
|
+
ON auth.sessions(user_sub) WHERE revoked_at IS NULL;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS auth.login_state (
|
|
23
|
+
state varchar(128) PRIMARY KEY,
|
|
24
|
+
code_verifier varchar(255) NOT NULL,
|
|
25
|
+
redirect_path text NOT NULL DEFAULT '/',
|
|
26
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
27
|
+
expires_at timestamptz NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX IF NOT EXISTS ix_auth_login_state_expires
|
|
31
|
+
ON auth.login_state(expires_at);
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quatronic/sdk",
|
|
3
|
+
"version": "0.0.0-canary-20260526130515",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"registry": "https://registry.npmjs.org",
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
"./auth": {
|
|
15
|
+
"types": "./dist/auth/index.d.ts",
|
|
16
|
+
"import": "./dist/auth/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./bff/auth": {
|
|
19
|
+
"types": "./dist/bff/auth/index.d.ts",
|
|
20
|
+
"import": "./dist/bff/auth/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"jose": "^5.9.6"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"drizzle-orm": ">=0.36",
|
|
28
|
+
"hono": ">=4.6",
|
|
29
|
+
"pg": ">=8.13",
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-dom": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"react": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"react-dom": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"hono": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"drizzle-orm": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"pg": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@testcontainers/postgresql": "^12.0.0",
|
|
52
|
+
"@testing-library/react": "^16.1.0",
|
|
53
|
+
"@types/node": "^22.10.2",
|
|
54
|
+
"@types/pg": "^8.11.10",
|
|
55
|
+
"@types/react": "^18.3.17",
|
|
56
|
+
"@types/react-dom": "^18.3.5",
|
|
57
|
+
"drizzle-kit": "^0.30.1",
|
|
58
|
+
"drizzle-orm": "^0.38.2",
|
|
59
|
+
"hono": "^4.6.13",
|
|
60
|
+
"jsdom": "^25.0.1",
|
|
61
|
+
"msw": "^2.7.0",
|
|
62
|
+
"pg": "^8.13.1",
|
|
63
|
+
"react": "^18.3.1",
|
|
64
|
+
"react-dom": "^18.3.1",
|
|
65
|
+
"testcontainers": "^10.16.0",
|
|
66
|
+
"tsup": "^8.3.5",
|
|
67
|
+
"typescript": "^5.7.2",
|
|
68
|
+
"vitest": "^2.1.8"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsup",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"test:watch": "vitest",
|
|
74
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
75
|
+
"lint": "eslint src tests"
|
|
76
|
+
}
|
|
77
|
+
}
|