@v8r/auth 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,3 @@
1
+ export declare const CLIENT_ID_LENGTH = 32;
2
+ export declare function clientIdFor(namespace: string): string;
3
+ export declare function namespaceFromClientId(clientId: string): string | null;
@@ -0,0 +1,21 @@
1
+ export const CLIENT_ID_LENGTH = 32;
2
+ const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9-]{1,30}$/;
3
+ export function clientIdFor(namespace) {
4
+ if (!NAMESPACE_PATTERN.test(namespace)) {
5
+ throw new Error("Invalid namespace.");
6
+ }
7
+ return namespace.padEnd(CLIENT_ID_LENGTH, "_");
8
+ }
9
+ export function namespaceFromClientId(clientId) {
10
+ if (clientId.length !== CLIENT_ID_LENGTH || !clientId.endsWith("_")) {
11
+ return null;
12
+ }
13
+ const namespace = clientId.replace(/_+$/u, "");
14
+ if (!NAMESPACE_PATTERN.test(namespace)) {
15
+ return null;
16
+ }
17
+ if (clientIdFor(namespace) !== clientId) {
18
+ return null;
19
+ }
20
+ return namespace;
21
+ }
@@ -0,0 +1,2 @@
1
+ export { CLIENT_ID_LENGTH, clientIdFor, namespaceFromClientId, } from "./client-id.js";
2
+ export { PRODUCTION_CONTROL_PLANE, PRODUCTION_ISSUER, getCdnCredentials, jwksFor, verifyIdToken, type V8rUser, } from "./verify.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { CLIENT_ID_LENGTH, clientIdFor, namespaceFromClientId, } from "./client-id.js";
2
+ export { PRODUCTION_CONTROL_PLANE, PRODUCTION_ISSUER, getCdnCredentials, jwksFor, verifyIdToken, } from "./verify.js";
package/dist/next.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { type V8rUser } from "./verify.js";
3
+ export type V8rAuthConfig = {
4
+ namespace: string;
5
+ token: string;
6
+ issuer?: string;
7
+ controlPlaneOrigin?: string;
8
+ };
9
+ export declare function createV8rAuth(config: V8rAuthConfig): {
10
+ clientId: string;
11
+ getUser: () => Promise<V8rUser | null>;
12
+ handlers: {
13
+ GET: (request: NextRequest) => Promise<NextResponse<unknown>>;
14
+ POST: (request: NextRequest) => Promise<NextResponse<unknown>>;
15
+ };
16
+ signIn(): never;
17
+ signOut(): never;
18
+ getCdnCredentials(options?: {
19
+ ttlSeconds?: number;
20
+ paths?: string[];
21
+ }): Promise<Record<string, unknown>>;
22
+ };
package/dist/next.js ADDED
@@ -0,0 +1,221 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { EncryptJWT, jwtDecrypt } from "jose";
3
+ import { cookies } from "next/headers";
4
+ import { redirect } from "next/navigation";
5
+ import { NextResponse } from "next/server";
6
+ import { clientIdFor } from "./client-id.js";
7
+ import { PRODUCTION_CONTROL_PLANE, PRODUCTION_ISSUER, getCdnCredentials, jwksFor, verifyIdToken, } from "./verify.js";
8
+ const SESSION_COOKIE = "v8r_session";
9
+ const PKCE_COOKIE = "v8r_pkce";
10
+ const SCOPES = "openid profile email offline_access";
11
+ function keyFor(token) {
12
+ return createHash("sha256").update(token).digest();
13
+ }
14
+ function base64Url(bytes) {
15
+ return bytes
16
+ .toString("base64")
17
+ .replaceAll("+", "-")
18
+ .replaceAll("/", "_")
19
+ .replaceAll("=", "");
20
+ }
21
+ async function seal(token, payload) {
22
+ return new EncryptJWT(payload)
23
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
24
+ .setIssuedAt()
25
+ .setExpirationTime("30d")
26
+ .encrypt(keyFor(token));
27
+ }
28
+ async function open(token, sealed) {
29
+ try {
30
+ const { payload } = await jwtDecrypt(sealed, keyFor(token));
31
+ if (typeof payload.refreshToken !== "string" ||
32
+ typeof payload.idToken !== "string") {
33
+ return null;
34
+ }
35
+ return { refreshToken: payload.refreshToken, idToken: payload.idToken };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function cookieOptions(maxAge) {
42
+ return {
43
+ httpOnly: true,
44
+ sameSite: "lax",
45
+ secure: process.env.NODE_ENV === "production",
46
+ path: "/",
47
+ maxAge,
48
+ };
49
+ }
50
+ function requestOrigin(request) {
51
+ const proto = request.headers.get("x-forwarded-proto") ??
52
+ request.nextUrl.protocol.replace(":", "");
53
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
54
+ return `${proto}://${host}`;
55
+ }
56
+ async function exchange(issuer, body) {
57
+ const response = await fetch(`${issuer}/oauth2/token`, {
58
+ method: "POST",
59
+ headers: { "content-type": "application/x-www-form-urlencoded" },
60
+ body,
61
+ });
62
+ return (await response.json());
63
+ }
64
+ export function createV8rAuth(config) {
65
+ const clientId = clientIdFor(config.namespace);
66
+ const issuer = config.issuer ?? PRODUCTION_ISSUER;
67
+ const controlPlaneOrigin = config.controlPlaneOrigin ?? PRODUCTION_CONTROL_PLANE;
68
+ async function readSession() {
69
+ const jar = await cookies();
70
+ const sealed = jar.get(SESSION_COOKIE)?.value;
71
+ if (!sealed) {
72
+ return null;
73
+ }
74
+ return open(config.token, sealed);
75
+ }
76
+ async function writeSession(jar, payload) {
77
+ jar.set(SESSION_COOKIE, await seal(config.token, payload), cookieOptions(60 * 60 * 24 * 30));
78
+ }
79
+ async function canWriteCookies(jar, current) {
80
+ try {
81
+ if (current) {
82
+ jar.set(SESSION_COOKIE, current, cookieOptions(60 * 60 * 24 * 30));
83
+ }
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ async function userFromIdToken(idToken) {
91
+ return verifyIdToken(idToken, {
92
+ issuer,
93
+ audience: clientId,
94
+ key: jwksFor(issuer),
95
+ });
96
+ }
97
+ async function refresh(session) {
98
+ const tokens = await exchange(issuer, new URLSearchParams({
99
+ grant_type: "refresh_token",
100
+ refresh_token: session.refreshToken,
101
+ client_id: clientId,
102
+ client_secret: config.token,
103
+ }));
104
+ if (!tokens.id_token || !tokens.refresh_token) {
105
+ return null;
106
+ }
107
+ return { idToken: tokens.id_token, refreshToken: tokens.refresh_token };
108
+ }
109
+ async function getUser() {
110
+ const session = await readSession();
111
+ if (!session) {
112
+ return null;
113
+ }
114
+ try {
115
+ const user = await userFromIdToken(session.idToken);
116
+ const payload = JSON.parse(Buffer.from(session.idToken.split(".")[1] ?? "", "base64url").toString());
117
+ const exp = payload.exp ?? 0;
118
+ if (exp * 1000 - Date.now() > 60_000) {
119
+ return user;
120
+ }
121
+ const jar = await cookies();
122
+ if (!(await canWriteCookies(jar, jar.get(SESSION_COOKIE)?.value))) {
123
+ return exp * 1000 > Date.now() ? user : null;
124
+ }
125
+ const next = await refresh(session);
126
+ if (!next) {
127
+ return exp * 1000 > Date.now() ? user : null;
128
+ }
129
+ await writeSession(jar, next);
130
+ return userFromIdToken(next.idToken);
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function GET(request) {
137
+ const path = request.nextUrl.pathname;
138
+ if (path.endsWith("/sign-in")) {
139
+ const verifier = base64Url(randomBytes(32));
140
+ const challenge = base64Url(createHash("sha256").update(verifier).digest());
141
+ const state = base64Url(randomBytes(16));
142
+ const redirectUri = `${requestOrigin(request)}/api/v8r/callback`;
143
+ const authorize = new URL(`${issuer}/oauth2/authorize`);
144
+ authorize.searchParams.set("response_type", "code");
145
+ authorize.searchParams.set("client_id", clientId);
146
+ authorize.searchParams.set("redirect_uri", redirectUri);
147
+ authorize.searchParams.set("scope", SCOPES);
148
+ authorize.searchParams.set("state", state);
149
+ authorize.searchParams.set("code_challenge", challenge);
150
+ authorize.searchParams.set("code_challenge_method", "S256");
151
+ const response = NextResponse.redirect(authorize);
152
+ response.cookies.set(PKCE_COOKIE, JSON.stringify({ verifier, state, redirectUri }), cookieOptions(600));
153
+ return response;
154
+ }
155
+ if (path.endsWith("/callback")) {
156
+ const jar = await cookies();
157
+ const stored = jar.get(PKCE_COOKIE)?.value;
158
+ const code = request.nextUrl.searchParams.get("code");
159
+ const state = request.nextUrl.searchParams.get("state");
160
+ if (!stored || !code || !state) {
161
+ return NextResponse.redirect(new URL("/", request.url));
162
+ }
163
+ const pkce = JSON.parse(stored);
164
+ if (pkce.state !== state) {
165
+ return NextResponse.redirect(new URL("/", request.url));
166
+ }
167
+ const tokens = await exchange(issuer, new URLSearchParams({
168
+ grant_type: "authorization_code",
169
+ code,
170
+ redirect_uri: pkce.redirectUri,
171
+ client_id: clientId,
172
+ client_secret: config.token,
173
+ code_verifier: pkce.verifier,
174
+ }));
175
+ const response = NextResponse.redirect(new URL("/", request.url));
176
+ response.cookies.delete(PKCE_COOKIE);
177
+ if (tokens.id_token && tokens.refresh_token) {
178
+ response.cookies.set(SESSION_COOKIE, await seal(config.token, {
179
+ idToken: tokens.id_token,
180
+ refreshToken: tokens.refresh_token,
181
+ }), cookieOptions(60 * 60 * 24 * 30));
182
+ }
183
+ return response;
184
+ }
185
+ if (path.endsWith("/sign-out")) {
186
+ const session = await readSession();
187
+ const response = NextResponse.redirect(session
188
+ ? `${issuer}/oauth2/end-session?${new URLSearchParams({
189
+ id_token_hint: session.idToken,
190
+ post_logout_redirect_uri: `${requestOrigin(request)}/`,
191
+ client_id: clientId,
192
+ })}`
193
+ : new URL("/", request.url));
194
+ response.cookies.delete(SESSION_COOKIE);
195
+ return response;
196
+ }
197
+ return NextResponse.json({ error: "Not found." }, { status: 404 });
198
+ }
199
+ return {
200
+ clientId,
201
+ getUser,
202
+ handlers: {
203
+ GET,
204
+ POST: GET,
205
+ },
206
+ signIn() {
207
+ redirect("/api/v8r/sign-in");
208
+ },
209
+ signOut() {
210
+ redirect("/api/v8r/sign-out");
211
+ },
212
+ getCdnCredentials(options) {
213
+ return getCdnCredentials({
214
+ token: config.token,
215
+ controlPlaneOrigin,
216
+ ttlSeconds: options?.ttlSeconds,
217
+ paths: options?.paths,
218
+ });
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,22 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ export declare const PRODUCTION_ISSUER = "https://auth.v8r.app/api/id";
3
+ export declare const PRODUCTION_CONTROL_PLANE = "https://www.v8r.dev";
4
+ export type V8rUser = {
5
+ id: string;
6
+ email: string;
7
+ emailVerified: boolean;
8
+ name: string | null;
9
+ image: string | null;
10
+ };
11
+ export declare function verifyIdToken(token: string, options: {
12
+ issuer: string;
13
+ audience: string;
14
+ key: JWTVerifyGetKey;
15
+ }): Promise<V8rUser>;
16
+ export declare function jwksFor(issuer: string): import("jose").RemoteJWKSet;
17
+ export declare function getCdnCredentials(options: {
18
+ token: string;
19
+ controlPlaneOrigin?: string;
20
+ ttlSeconds?: number;
21
+ paths?: string[];
22
+ }): Promise<Record<string, unknown>>;
package/dist/verify.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ export const PRODUCTION_ISSUER = "https://auth.v8r.app/api/id";
3
+ export const PRODUCTION_CONTROL_PLANE = "https://www.v8r.dev";
4
+ export async function verifyIdToken(token, options) {
5
+ const { payload } = await jwtVerify(token, options.key, {
6
+ issuer: options.issuer,
7
+ audience: options.audience,
8
+ });
9
+ if (!payload.sub) {
10
+ throw new Error("ID token is missing sub.");
11
+ }
12
+ return {
13
+ id: payload.sub,
14
+ email: typeof payload.email === "string" ? payload.email : "",
15
+ emailVerified: payload.email_verified === true,
16
+ name: typeof payload.name === "string" ? payload.name : null,
17
+ image: typeof payload.picture === "string" ? payload.picture : null,
18
+ };
19
+ }
20
+ const jwksCache = new Map();
21
+ export function jwksFor(issuer) {
22
+ const existing = jwksCache.get(issuer);
23
+ if (existing) {
24
+ return existing;
25
+ }
26
+ const jwks = createRemoteJWKSet(new URL(`${issuer}/jwks`));
27
+ jwksCache.set(issuer, jwks);
28
+ return jwks;
29
+ }
30
+ export async function getCdnCredentials(options) {
31
+ const origin = options.controlPlaneOrigin ?? PRODUCTION_CONTROL_PLANE;
32
+ const response = await fetch(`${origin}/api/cdn/credentials`, {
33
+ method: "POST",
34
+ headers: {
35
+ authorization: `Bearer ${options.token}`,
36
+ "content-type": "application/json",
37
+ },
38
+ body: JSON.stringify({
39
+ ttlSeconds: options.ttlSeconds,
40
+ paths: options.paths,
41
+ }),
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`CDN credentials failed (${response.status}).`);
45
+ }
46
+ return response.json();
47
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@v8r/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/patrickhulce/v8r.dev.git",
15
+ "directory": "packages/auth"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./next": {
23
+ "types": "./dist/next.d.ts",
24
+ "import": "./dist/next.js"
25
+ }
26
+ },
27
+ "dependencies": {
28
+ "jose": "^6.2.12"
29
+ },
30
+ "peerDependencies": {
31
+ "next": ">=16"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "next": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20",
40
+ "@types/react": "^19",
41
+ "next": "16.3.1",
42
+ "react": "19.2.8",
43
+ "tsx": "^4.23.12",
44
+ "typescript": "^5"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "test": "tsx --test \"src/**/*.test.ts\""
49
+ }
6
50
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.