@frigear-nu/oauth-server 0.0.0-stage → 0.0.3

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.
Files changed (73) hide show
  1. package/README.md +49 -2
  2. package/dist/module.d.mts +206 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +107 -0
  5. package/dist/runtime/app/composables/index.d.ts +1 -0
  6. package/dist/runtime/app/composables/index.js +1 -0
  7. package/dist/runtime/app/composables/useOAuthApplications.d.ts +19 -0
  8. package/dist/runtime/app/composables/useOAuthApplications.js +41 -0
  9. package/dist/runtime/app/pages/admin.d.vue.ts +3 -0
  10. package/dist/runtime/app/pages/admin.vue +384 -0
  11. package/dist/runtime/app/pages/admin.vue.d.ts +3 -0
  12. package/dist/runtime/app/pages/applications.d.vue.ts +3 -0
  13. package/dist/runtime/app/pages/applications.vue +50 -0
  14. package/dist/runtime/app/pages/applications.vue.d.ts +3 -0
  15. package/dist/runtime/app/pages/authorize.d.vue.ts +3 -0
  16. package/dist/runtime/app/pages/authorize.vue +108 -0
  17. package/dist/runtime/app/pages/authorize.vue.d.ts +3 -0
  18. package/dist/runtime/menus.d.ts +2 -0
  19. package/dist/runtime/menus.js +27 -0
  20. package/dist/runtime/server/db/schema.sqlite.d.ts +682 -0
  21. package/dist/runtime/server/db/schema.sqlite.js +60 -0
  22. package/dist/runtime/server/routes/api/admin/application-revoke.post.d.ts +2 -0
  23. package/dist/runtime/server/routes/api/admin/application-revoke.post.js +7 -0
  24. package/dist/runtime/server/routes/api/admin/application-secret.post.d.ts +2 -0
  25. package/dist/runtime/server/routes/api/admin/application-secret.post.js +5 -0
  26. package/dist/runtime/server/routes/api/admin/application.delete.d.ts +2 -0
  27. package/dist/runtime/server/routes/api/admin/application.delete.js +6 -0
  28. package/dist/runtime/server/routes/api/admin/application.get.d.ts +2 -0
  29. package/dist/runtime/server/routes/api/admin/application.get.js +6 -0
  30. package/dist/runtime/server/routes/api/admin/application.patch.d.ts +2 -0
  31. package/dist/runtime/server/routes/api/admin/application.patch.js +9 -0
  32. package/dist/runtime/server/routes/api/admin/applications.get.d.ts +2 -0
  33. package/dist/runtime/server/routes/api/admin/applications.get.js +11 -0
  34. package/dist/runtime/server/routes/api/admin/applications.post.d.ts +2 -0
  35. package/dist/runtime/server/routes/api/admin/applications.post.js +8 -0
  36. package/dist/runtime/server/routes/api/consent.get.d.ts +2 -0
  37. package/dist/runtime/server/routes/api/consent.get.js +19 -0
  38. package/dist/runtime/server/routes/api/me-applications.get.d.ts +2 -0
  39. package/dist/runtime/server/routes/api/me-applications.get.js +4 -0
  40. package/dist/runtime/server/routes/oidc/authorize.get.d.ts +2 -0
  41. package/dist/runtime/server/routes/oidc/authorize.get.js +15 -0
  42. package/dist/runtime/server/routes/oidc/authorize.post.d.ts +2 -0
  43. package/dist/runtime/server/routes/oidc/authorize.post.js +27 -0
  44. package/dist/runtime/server/routes/oidc/jwks.get.d.ts +2 -0
  45. package/dist/runtime/server/routes/oidc/jwks.get.js +4 -0
  46. package/dist/runtime/server/routes/oidc/openid-configuration.get.d.ts +2 -0
  47. package/dist/runtime/server/routes/oidc/openid-configuration.get.js +21 -0
  48. package/dist/runtime/server/routes/oidc/revoke.post.d.ts +2 -0
  49. package/dist/runtime/server/routes/oidc/revoke.post.js +13 -0
  50. package/dist/runtime/server/routes/oidc/token.post.d.ts +2 -0
  51. package/dist/runtime/server/routes/oidc/token.post.js +56 -0
  52. package/dist/runtime/server/routes/oidc/userinfo.get.d.ts +2 -0
  53. package/dist/runtime/server/routes/oidc/userinfo.get.js +18 -0
  54. package/dist/runtime/server/utils/oauth-access.d.ts +34 -0
  55. package/dist/runtime/server/utils/oauth-access.js +49 -0
  56. package/dist/runtime/server/utils/oauth-applications.d.ts +65 -0
  57. package/dist/runtime/server/utils/oauth-applications.js +126 -0
  58. package/dist/runtime/server/utils/oauth-authorize.d.ts +29 -0
  59. package/dist/runtime/server/utils/oauth-authorize.js +33 -0
  60. package/dist/runtime/server/utils/oauth-config.d.ts +16 -0
  61. package/dist/runtime/server/utils/oauth-config.js +20 -0
  62. package/dist/runtime/server/utils/oauth-crypto.d.ts +18 -0
  63. package/dist/runtime/server/utils/oauth-crypto.js +48 -0
  64. package/dist/runtime/server/utils/oauth-grants.d.ts +60 -0
  65. package/dist/runtime/server/utils/oauth-grants.js +70 -0
  66. package/dist/runtime/server/utils/oauth-keys.d.ts +41 -0
  67. package/dist/runtime/server/utils/oauth-keys.js +55 -0
  68. package/dist/runtime/server/utils/oauth-tokens.d.ts +25 -0
  69. package/dist/runtime/server/utils/oauth-tokens.js +34 -0
  70. package/dist/runtime/utils/index.d.ts +24 -0
  71. package/dist/runtime/utils/index.js +51 -0
  72. package/dist/types.d.mts +3 -0
  73. package/package.json +92 -3
@@ -0,0 +1,126 @@
1
+ import { and, desc, eq, inArray } from "drizzle-orm";
2
+ import { db, schema } from "@nuxthub/db";
3
+ import { z } from "zod";
4
+ import { isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
5
+ const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
6
+ const httpsUrl = z.string().trim().max(2048).refine((value) => {
7
+ try {
8
+ return isSecureOrigin(new URL(value));
9
+ } catch {
10
+ return false;
11
+ }
12
+ }, "Must be a https URL (http is only allowed for localhost).");
13
+ const names = z.array(z.string().trim().min(1).max(64)).max(50);
14
+ export const applicationInputSchema = z.object({
15
+ name: z.string().trim().min(1).max(120),
16
+ websiteUrl: z.string().trim().max(2048),
17
+ description: z.string().trim().max(1e3).nullish(),
18
+ icon: z.string().trim().max(120).nullish(),
19
+ priority: z.number().int().min(-1e3).max(1e3).optional(),
20
+ loginUrl: httpsUrl.nullish(),
21
+ previewUrlPattern: z.string().trim().max(2048).startsWith("https://").nullish(),
22
+ redirectUris: z.array(httpsUrl).max(20).optional(),
23
+ allowedRoles: names.optional(),
24
+ tags: names.optional(),
25
+ isActive: z.boolean().optional()
26
+ });
27
+ export const applicationPatchSchema = applicationInputSchema.partial();
28
+ export function toApplication(row) {
29
+ const { secretHash: _secretHash, ...application } = row;
30
+ return application;
31
+ }
32
+ function normalizeInput(input) {
33
+ const next = { ...input };
34
+ if (next.websiteUrl !== void 0) {
35
+ try {
36
+ next.websiteUrl = normalizeWebsiteUrl(next.websiteUrl);
37
+ } catch (error) {
38
+ throw fail(400, error.message);
39
+ }
40
+ }
41
+ if (next.allowedRoles) next.allowedRoles = [...new Set(next.allowedRoles)].sort();
42
+ if (next.tags) next.tags = [...new Set(next.tags)];
43
+ return next;
44
+ }
45
+ export async function getApplicationRow(id) {
46
+ const [row] = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).limit(1);
47
+ return row ?? null;
48
+ }
49
+ export async function getApplication(id) {
50
+ const row = await getApplicationRow(id);
51
+ return row ? toApplication(row) : null;
52
+ }
53
+ export async function listApplications() {
54
+ const rows = await db.select().from(schema.oauthApplications).orderBy(desc(schema.oauthApplications.priority), schema.oauthApplications.name);
55
+ return rows.map(toApplication);
56
+ }
57
+ export async function createApplication(input, ownerId) {
58
+ const data = normalizeInput(applicationInputSchema.parse(input));
59
+ const secret = generateSecret();
60
+ const [row] = await db.insert(schema.oauthApplications).values({
61
+ id: newId("app"),
62
+ secretHash: await sha256Hex(secret),
63
+ ownerId: ownerId ?? null,
64
+ ...data
65
+ }).returning();
66
+ return { application: toApplication(row), secret };
67
+ }
68
+ export async function updateApplication(id, patch) {
69
+ const data = normalizeInput(applicationPatchSchema.parse(patch));
70
+ const [row] = Object.keys(data).length ? await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning() : [await getApplicationRow(id)];
71
+ if (!row) throw fail(404, "Application not found.");
72
+ return toApplication(row);
73
+ }
74
+ export async function deleteApplication(id) {
75
+ await db.delete(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.applicationId, id));
76
+ await db.delete(schema.oauthAuthorizationCodes).where(eq(schema.oauthAuthorizationCodes.applicationId, id));
77
+ const deleted = await db.delete(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
78
+ if (!deleted.length) throw fail(404, "Application not found.");
79
+ }
80
+ export async function rotateApplicationSecret(id) {
81
+ const secret = generateSecret();
82
+ const updated = await db.update(schema.oauthApplications).set({ secretHash: await sha256Hex(secret) }).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
83
+ if (!updated.length) throw fail(404, "Application not found.");
84
+ await revokeApplicationGrants(id);
85
+ return { secret };
86
+ }
87
+ export async function verifyApplicationSecret(row, secret) {
88
+ return safeEqual(await sha256Hex(secret), row.secretHash);
89
+ }
90
+ export async function revokeApplicationGrants(applicationId) {
91
+ await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq(schema.oauthRefreshTokens.applicationId, applicationId));
92
+ await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId, userId: null, reason: "revoked" });
93
+ }
94
+ export async function revokeUserGrants(userId, applicationId) {
95
+ const where = applicationId ? and(eq(schema.oauthRefreshTokens.userId, userId), eq(schema.oauthRefreshTokens.applicationId, applicationId)) : eq(schema.oauthRefreshTokens.userId, userId);
96
+ const revoked = await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(where).returning({ applicationId: schema.oauthRefreshTokens.applicationId });
97
+ const hooks = useNitroApp().hooks;
98
+ for (const id of new Set(revoked.map((row) => row.applicationId))) {
99
+ await hooks.callHook("oauth:token:revoked", { applicationId: id, userId, reason: "user_grants_revoked" });
100
+ }
101
+ await db.delete(schema.oauthAuthorizationCodes).where(applicationId ? and(eq(schema.oauthAuthorizationCodes.userId, userId), eq(schema.oauthAuthorizationCodes.applicationId, applicationId)) : eq(schema.oauthAuthorizationCodes.userId, userId));
102
+ }
103
+ export async function listUserApplications(userId, event) {
104
+ const roles = await resolveUserRoles(userId, event);
105
+ const rows = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.isActive, true)).orderBy(desc(schema.oauthApplications.priority), schema.oauthApplications.name);
106
+ return rows.filter((row) => matchesAllowedRoles(row.allowedRoles, roles)).map((row) => ({
107
+ id: row.id,
108
+ name: row.name,
109
+ description: row.description,
110
+ icon: row.icon,
111
+ priority: row.priority,
112
+ websiteUrl: row.websiteUrl,
113
+ tags: row.tags,
114
+ loginUrl: row.loginUrl || row.websiteUrl
115
+ }));
116
+ }
117
+ export async function countActiveGrants(applicationIds) {
118
+ if (!applicationIds.length) return {};
119
+ const rows = await db.select({ applicationId: schema.oauthRefreshTokens.applicationId, revokedAt: schema.oauthRefreshTokens.revokedAt, expiresAt: schema.oauthRefreshTokens.expiresAt }).from(schema.oauthRefreshTokens).where(inArray(schema.oauthRefreshTokens.applicationId, applicationIds));
120
+ const now = Date.now();
121
+ const counts = {};
122
+ for (const row of rows) {
123
+ if (!row.revokedAt && row.expiresAt.getTime() > now) counts[row.applicationId] = (counts[row.applicationId] ?? 0) + 1;
124
+ }
125
+ return counts;
126
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import type { H3Event } from 'h3';
3
+ import type { OAuthApplication, OAuthUser } from '../../../types.js';
4
+ /** The authorization request parameters (query on GET, body on POST). */
5
+ export declare const authorizeRequestSchema: z.ZodObject<{
6
+ client_id: z.ZodString;
7
+ redirect_uri: z.ZodString;
8
+ response_type: z.ZodLiteral<"code">;
9
+ state: z.ZodString;
10
+ code_challenge: z.ZodString;
11
+ code_challenge_method: z.ZodLiteral<"S256">;
12
+ scope: z.ZodOptional<z.ZodString>;
13
+ nonce: z.ZodOptional<z.ZodString>;
14
+ }, z.core.$strip>;
15
+ export type AuthorizeRequest = z.infer<typeof authorizeRequestSchema>;
16
+ /**
17
+ * Parses and validates an authorization request: known, active client, an allowed redirect URI, PKCE
18
+ * (S256 is mandatory) and the scopes. Failures are plain HTTP errors because the redirect URI cannot
19
+ * be trusted to carry them yet.
20
+ */
21
+ export declare function validateAuthorizeRequest(input: unknown): Promise<{
22
+ request: AuthorizeRequest;
23
+ application: OAuthApplication;
24
+ scope: string[];
25
+ }>;
26
+ /** Throws 403 unless the user may use the application (role check, active, `oauth:application:access`). */
27
+ export declare function assertApplicationAccess(event: H3Event, user: OAuthUser, application: OAuthApplication): Promise<void>;
28
+ /** Where to send people who are not signed in: the configured path or the `@frigear-nu/users` login page. */
29
+ export declare function getSignInPath(): string;
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { resolveScope, validateRedirectUri } from "../../utils/index.js";
3
+ export const authorizeRequestSchema = z.object({
4
+ client_id: z.string().min(1),
5
+ redirect_uri: z.string().min(1),
6
+ response_type: z.literal("code"),
7
+ state: z.string().min(1).max(2048),
8
+ code_challenge: z.string().min(43).max(128),
9
+ code_challenge_method: z.literal("S256"),
10
+ scope: z.string().max(1024).optional(),
11
+ nonce: z.string().max(512).optional()
12
+ });
13
+ const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
14
+ export async function validateAuthorizeRequest(input) {
15
+ const parsed = authorizeRequestSchema.safeParse(input);
16
+ if (!parsed.success) throw fail(400, `Invalid authorization request: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
17
+ const request = parsed.data;
18
+ const application = await getApplication(request.client_id);
19
+ if (!application || !application.isActive) throw fail(400, "Unknown or disabled application.");
20
+ const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath });
21
+ if (!valid) throw fail(400, "The redirect_uri is not allowed for this application.");
22
+ const scope = resolveScope(request.scope, getSupportedScopes());
23
+ if (scope.length === 0) throw fail(400, "None of the requested scopes is supported.");
24
+ return { request, application, scope };
25
+ }
26
+ export async function assertApplicationAccess(event, user, application) {
27
+ const access = await checkApplicationAccess(user, application, event);
28
+ if (!access.allowed) throw fail(403, access.reason ?? "You do not have access to this application.");
29
+ }
30
+ export function getSignInPath() {
31
+ const config = useRuntimeConfig();
32
+ return config.oauthServer.signInPath || config.public.users?.pages?.login || "/login";
33
+ }
@@ -0,0 +1,16 @@
1
+ import type { H3Event } from 'h3';
2
+ export declare function useOAuthConfig(): any;
3
+ /** The configured issuer, or the origin the request came in on (honouring `x-forwarded-*`). */
4
+ export declare function getOAuthIssuer(event: H3Event): string;
5
+ /** Scopes this server accepts: the defaults plus whatever the host configured. */
6
+ export declare function getSupportedScopes(): string[];
7
+ /** The signed-in user's numeric id (the `users` table uses integer ids). Responds 401 when nobody is signed in. */
8
+ export declare function requireSessionUserId(event: H3Event): Promise<number>;
9
+ /**
10
+ * OAuth style error body (RFC 6749 §5.2). Return it from a handler:
11
+ * `return oauthError(event, 400, 'invalid_grant', 'The code expired.')`.
12
+ */
13
+ export declare function oauthError(event: H3Event, statusCode: number, error: string, description?: string): {
14
+ error_description?: string | undefined;
15
+ error: string;
16
+ };
@@ -0,0 +1,20 @@
1
+ export function useOAuthConfig() {
2
+ return useRuntimeConfig().oauthServer;
3
+ }
4
+ export function getOAuthIssuer(event) {
5
+ const configured = useOAuthConfig().issuer;
6
+ const issuer = configured || getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin;
7
+ return issuer.replace(/\/+$/, "");
8
+ }
9
+ export function getSupportedScopes() {
10
+ return [.../* @__PURE__ */ new Set(["openid", "profile", "email", ...useOAuthConfig().scopes])];
11
+ }
12
+ export async function requireSessionUserId(event) {
13
+ const { user } = await requireUserSession(event);
14
+ return Number(user.id);
15
+ }
16
+ export function oauthError(event, statusCode, error, description) {
17
+ setResponseStatus(event, statusCode);
18
+ setResponseHeader(event, "Cache-Control", "no-store");
19
+ return { error, ...description ? { error_description: description } : {} };
20
+ }
@@ -0,0 +1,18 @@
1
+ import type { H3Event } from 'h3';
2
+ /** Random, URL-safe secret (codes, refresh tokens, client secrets). */
3
+ export declare function generateSecret(bytes?: number): string;
4
+ export declare function sha256Hex(value: string): Promise<string>;
5
+ /** Constant-time string comparison. */
6
+ export declare function safeEqual(a: string, b: string): boolean;
7
+ /** PKCE S256: `BASE64URL(SHA256(verifier))`. */
8
+ export declare function computeCodeChallenge(verifier: string): Promise<string>;
9
+ export declare function verifyCodeChallenge(verifier: string, challenge: string): Promise<boolean>;
10
+ /**
11
+ * Client credentials from the `Authorization: Basic` header (id and secret are form-urlencoded and
12
+ * split on the *first* colon, RFC 6749 §2.3.1) or from the request body.
13
+ */
14
+ export declare function readClientCredentials(event: H3Event, body: Record<string, unknown> | undefined): {
15
+ clientId: string;
16
+ clientSecret: string;
17
+ basic: boolean;
18
+ } | null;
@@ -0,0 +1,48 @@
1
+ const encoder = new TextEncoder();
2
+ function toHex(bytes) {
3
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4
+ }
5
+ function toBase64Url(bytes) {
6
+ let binary = "";
7
+ for (const byte of bytes) binary += String.fromCharCode(byte);
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+ export function generateSecret(bytes = 32) {
11
+ return toHex(crypto.getRandomValues(new Uint8Array(bytes)));
12
+ }
13
+ export async function sha256Hex(value) {
14
+ return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))));
15
+ }
16
+ export function safeEqual(a, b) {
17
+ const left = encoder.encode(a);
18
+ const right = encoder.encode(b);
19
+ let diff = left.length ^ right.length;
20
+ for (let i = 0; i < Math.max(left.length, right.length); i++) diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
21
+ return diff === 0;
22
+ }
23
+ export async function computeCodeChallenge(verifier) {
24
+ return toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
25
+ }
26
+ export async function verifyCodeChallenge(verifier, challenge) {
27
+ return safeEqual(await computeCodeChallenge(verifier), challenge);
28
+ }
29
+ export function readClientCredentials(event, body) {
30
+ const header = getHeader(event, "authorization");
31
+ if (header?.toLowerCase().startsWith("basic ")) {
32
+ try {
33
+ const decoded = atob(header.slice(6).trim());
34
+ const index = decoded.indexOf(":");
35
+ if (index === -1) return null;
36
+ return {
37
+ clientId: decodeURIComponent(decoded.slice(0, index).replace(/\+/g, " ")),
38
+ clientSecret: decodeURIComponent(decoded.slice(index + 1).replace(/\+/g, " ")),
39
+ basic: true
40
+ };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ const clientId = typeof body?.client_id === "string" ? body.client_id : "";
46
+ const clientSecret = typeof body?.client_secret === "string" ? body.client_secret : "";
47
+ return clientId && clientSecret ? { clientId, clientSecret, basic: false } : null;
48
+ }
@@ -0,0 +1,60 @@
1
+ import { schema } from '@nuxthub/db';
2
+ type ApplicationRow = typeof schema.oauthApplications.$inferSelect;
3
+ export type AuthorizationGrant = {
4
+ userId: number;
5
+ scope: string[];
6
+ nonce: string | null;
7
+ authTime: Date;
8
+ };
9
+ /** Stores a single-use authorization code (only its hash) and returns the plain code for the redirect. */
10
+ export declare function createAuthorizationCode(input: {
11
+ application: Pick<ApplicationRow, 'id'>;
12
+ userId: number;
13
+ redirectUri: string;
14
+ scope: string[];
15
+ codeChallenge: string;
16
+ nonce?: string | null;
17
+ }): Promise<string>;
18
+ /**
19
+ * Redeems a code. It is consumed even when validation fails afterwards (a code is only ever valid for
20
+ * one attempt). Returns a human readable `error` instead of throwing so the token endpoint can answer
21
+ * with an OAuth error body.
22
+ */
23
+ export declare function exchangeAuthorizationCode(input: {
24
+ code: string;
25
+ application: Pick<ApplicationRow, 'id'>;
26
+ redirectUri: string;
27
+ codeVerifier: string;
28
+ }): Promise<{
29
+ grant: AuthorizationGrant;
30
+ } | {
31
+ error: string;
32
+ }>;
33
+ /** Creates a refresh token; pass `familyId` when rotating so the chain stays together. */
34
+ export declare function createRefreshToken(input: {
35
+ applicationId: string;
36
+ userId: number;
37
+ scope: string[];
38
+ authTime: Date;
39
+ familyId?: string;
40
+ }): Promise<{
41
+ token: string;
42
+ id: string;
43
+ familyId: string;
44
+ }>;
45
+ /**
46
+ * Exchanges a refresh token for a new one (rotation). Presenting a token that was already rotated or
47
+ * revoked is treated as theft: the whole family is revoked.
48
+ */
49
+ export declare function rotateRefreshToken(input: {
50
+ token: string;
51
+ application: Pick<ApplicationRow, 'id'>;
52
+ }): Promise<{
53
+ grant: AuthorizationGrant;
54
+ token: string;
55
+ } | {
56
+ error: string;
57
+ }>;
58
+ /** Revokes the family of a refresh token (RFC 7009). Unknown tokens are ignored. */
59
+ export declare function revokeRefreshToken(token: string, applicationId: string): Promise<void>;
60
+ export {};
@@ -0,0 +1,70 @@
1
+ import { and, eq, isNull, lt } from "drizzle-orm";
2
+ import { db, schema } from "@nuxthub/db";
3
+ import { newId } from "../../utils/index.js";
4
+ export async function createAuthorizationCode(input) {
5
+ const code = generateSecret();
6
+ await db.delete(schema.oauthAuthorizationCodes).where(lt(schema.oauthAuthorizationCodes.expiresAt, /* @__PURE__ */ new Date()));
7
+ await db.insert(schema.oauthAuthorizationCodes).values({
8
+ codeHash: await sha256Hex(code),
9
+ applicationId: input.application.id,
10
+ userId: input.userId,
11
+ redirectUri: input.redirectUri,
12
+ scope: input.scope.join(" "),
13
+ codeChallenge: input.codeChallenge,
14
+ nonce: input.nonce ?? null,
15
+ expiresAt: new Date(Date.now() + useOAuthConfig().codeTtl * 1e3)
16
+ });
17
+ return code;
18
+ }
19
+ export async function exchangeAuthorizationCode(input) {
20
+ const [row] = await db.delete(schema.oauthAuthorizationCodes).where(eq(schema.oauthAuthorizationCodes.codeHash, await sha256Hex(input.code))).returning();
21
+ if (!row) return { error: "The authorization code is invalid or already used." };
22
+ if (row.applicationId !== input.application.id) return { error: "The authorization code was issued to another application." };
23
+ if (row.expiresAt.getTime() < Date.now()) return { error: "The authorization code has expired." };
24
+ if (row.redirectUri !== input.redirectUri) return { error: "The redirect_uri does not match the authorization request." };
25
+ if (!await verifyCodeChallenge(input.codeVerifier, row.codeChallenge)) return { error: "The code_verifier does not match the code_challenge." };
26
+ return { grant: { userId: row.userId, scope: row.scope.split(" ").filter(Boolean), nonce: row.nonce, authTime: row.authTime } };
27
+ }
28
+ export async function createRefreshToken(input) {
29
+ const token = generateSecret(48);
30
+ const id = newId("rt");
31
+ const familyId = input.familyId ?? newId("rtf");
32
+ await db.insert(schema.oauthRefreshTokens).values({
33
+ id,
34
+ tokenHash: await sha256Hex(token),
35
+ applicationId: input.applicationId,
36
+ userId: input.userId,
37
+ scope: input.scope.join(" "),
38
+ familyId,
39
+ authTime: input.authTime,
40
+ expiresAt: new Date(Date.now() + useOAuthConfig().refreshTokenTtl * 1e3)
41
+ });
42
+ return { token, id, familyId };
43
+ }
44
+ async function revokeFamily(familyId) {
45
+ await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and(eq(schema.oauthRefreshTokens.familyId, familyId), isNull(schema.oauthRefreshTokens.revokedAt)));
46
+ }
47
+ export async function rotateRefreshToken(input) {
48
+ const [row] = await db.select().from(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.tokenHash, await sha256Hex(input.token))).limit(1);
49
+ if (!row || row.applicationId !== input.application.id) return { error: "The refresh token is invalid." };
50
+ if (row.revokedAt || row.replacedBy) {
51
+ await revokeFamily(row.familyId);
52
+ await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId: row.applicationId, userId: row.userId, reason: "reuse_detected" });
53
+ return { error: "The refresh token was already used or revoked." };
54
+ }
55
+ if (row.expiresAt.getTime() < Date.now()) return { error: "The refresh token has expired." };
56
+ const scope = row.scope.split(" ").filter(Boolean);
57
+ const next = await createRefreshToken({ applicationId: row.applicationId, userId: row.userId, scope, authTime: row.authTime, familyId: row.familyId });
58
+ const claimed = await db.update(schema.oauthRefreshTokens).set({ replacedBy: next.id, revokedAt: /* @__PURE__ */ new Date() }).where(and(eq(schema.oauthRefreshTokens.id, row.id), isNull(schema.oauthRefreshTokens.replacedBy), isNull(schema.oauthRefreshTokens.revokedAt))).returning({ id: schema.oauthRefreshTokens.id });
59
+ if (!claimed.length) {
60
+ await revokeFamily(row.familyId);
61
+ return { error: "The refresh token was already used or revoked." };
62
+ }
63
+ return { grant: { userId: row.userId, scope, nonce: null, authTime: row.authTime }, token: next.token };
64
+ }
65
+ export async function revokeRefreshToken(token, applicationId) {
66
+ const [row] = await db.select().from(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.tokenHash, await sha256Hex(token))).limit(1);
67
+ if (!row || row.applicationId !== applicationId) return;
68
+ await revokeFamily(row.familyId);
69
+ await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId, userId: row.userId, reason: "revoked" });
70
+ }
@@ -0,0 +1,41 @@
1
+ import type { JWK } from 'jose';
2
+ /** Accepts PEMs pasted into env files: strips wrapping quotes and turns literal `\n` into newlines. */
3
+ export declare function normalizePemKey(raw: string): string;
4
+ export declare function getSigningKey(): Promise<globalThis.CryptoKey>;
5
+ /**
6
+ * The JSON Web Key Set served at `/.well-known/jwks.json`: the current key plus the previous public keys
7
+ * that are kept during a rotation. Never contains private key material.
8
+ */
9
+ export declare function getJwks(): Promise<{
10
+ keys: (JWK | {
11
+ kid: any;
12
+ alg: string;
13
+ use: string;
14
+ kty?: import("jose").JWKKeyType;
15
+ key_ops?: string[];
16
+ ext?: boolean;
17
+ x5c?: string[];
18
+ x5t?: string;
19
+ 'x5t#S256'?: string;
20
+ x5u?: string;
21
+ crv?: string;
22
+ d?: string;
23
+ dp?: string;
24
+ dq?: string;
25
+ e?: string;
26
+ k?: string;
27
+ n?: string;
28
+ p?: string;
29
+ q?: string;
30
+ qi?: string;
31
+ x?: string;
32
+ y?: string;
33
+ pub?: string;
34
+ priv?: string;
35
+ oth?: Array<{
36
+ d?: string;
37
+ r?: string;
38
+ t?: string;
39
+ }>;
40
+ })[];
41
+ }>;
@@ -0,0 +1,55 @@
1
+ import { exportJWK, importPKCS8, importSPKI } from "jose";
2
+ export function normalizePemKey(raw) {
3
+ return raw.trim().replace(/^["']|["']$/g, "").replace(/\\n/g, "\n").trim();
4
+ }
5
+ const notConfigured = () => createError({ statusCode: 500, statusMessage: "The OAuth server signing keys are not configured." });
6
+ let cache;
7
+ async function loadKeys() {
8
+ const config = useOAuthConfig();
9
+ const pem = config.privateKey ? normalizePemKey(config.privateKey) : "";
10
+ if (!pem) throw notConfigured();
11
+ const publicPem = config.publicKey ? normalizePemKey(config.publicKey) : "";
12
+ if (cache?.pem === pem && cache.publicPem === publicPem) return cache;
13
+ const privateKey = await importPKCS8(pem, "RS256", { extractable: true });
14
+ let publicJwk;
15
+ if (publicPem) {
16
+ publicJwk = await exportJWK(await importSPKI(publicPem, "RS256", { extractable: true }));
17
+ } else {
18
+ const { kty, n, e } = await exportJWK(privateKey);
19
+ publicJwk = { kty, n, e };
20
+ }
21
+ cache = { pem, publicPem, privateKey, publicJwk };
22
+ return cache;
23
+ }
24
+ export async function getSigningKey() {
25
+ return (await loadKeys()).privateKey;
26
+ }
27
+ function parsePreviousKeys() {
28
+ const raw = useOAuthConfig().previousPublicKeys;
29
+ if (!raw) return {};
30
+ try {
31
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
32
+ return Object.fromEntries(Object.entries(parsed).map(([kid, pem]) => [kid, normalizePemKey(String(pem))]));
33
+ } catch {
34
+ throw createError({ statusCode: 500, statusMessage: "The OAuth server previous public keys are not valid JSON." });
35
+ }
36
+ }
37
+ let previousCache;
38
+ async function loadPreviousJwks() {
39
+ const keys = parsePreviousKeys();
40
+ const source = JSON.stringify(keys);
41
+ if (previousCache?.source === source) return previousCache.jwks;
42
+ const jwks = [];
43
+ for (const [kid, pem] of Object.entries(keys)) {
44
+ const { kty, n, e } = await exportJWK(await importSPKI(pem, "RS256", { extractable: true }));
45
+ jwks.push({ kty, n, e, kid, alg: "RS256", use: "sig" });
46
+ }
47
+ previousCache = { source, jwks };
48
+ return jwks;
49
+ }
50
+ export async function getJwks() {
51
+ const { publicJwk } = await loadKeys();
52
+ const keyId = useOAuthConfig().keyId;
53
+ const previous = (await loadPreviousJwks()).filter((jwk) => jwk.kid !== keyId);
54
+ return { keys: [{ ...publicJwk, kid: keyId, alg: "RS256", use: "sig" }, ...previous] };
55
+ }
@@ -0,0 +1,25 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { OAuthApplication, OAuthUser } from '../../../types.js';
3
+ type TokenInput = {
4
+ event: H3Event;
5
+ user: OAuthUser;
6
+ application: OAuthApplication;
7
+ scope: string[];
8
+ roles: string[];
9
+ };
10
+ /** A bare access token (`sub`, `scope`, `client_id`) plus whatever `oauth:claims` adds. Header `typ` is `at+jwt`. */
11
+ export declare function signAccessToken(input: TokenInput): Promise<string>;
12
+ /** An OIDC ID token with the profile claims of the granted scopes, `nonce` and `auth_time`. */
13
+ export declare function signIdToken(input: TokenInput & {
14
+ nonce?: string | null;
15
+ authTime: Date;
16
+ }): Promise<string>;
17
+ export type AccessTokenPayload = {
18
+ sub: string;
19
+ aud: string;
20
+ scope: string;
21
+ client_id: string;
22
+ } & Record<string, unknown>;
23
+ /** Verifies signature, expiry, issuer and the `at+jwt` type of an access token. Returns `null` when invalid. */
24
+ export declare function verifyAccessToken(event: H3Event, token: string): Promise<AccessTokenPayload | null>;
25
+ export {};
@@ -0,0 +1,34 @@
1
+ import { SignJWT, createLocalJWKSet, jwtVerify } from "jose";
2
+ async function sign(event, application, userId, claims, options) {
3
+ const config = useOAuthConfig();
4
+ return new SignJWT(claims).setProtectedHeader({ alg: "RS256", typ: options.typ, kid: config.keyId }).setIssuer(getOAuthIssuer(event)).setSubject(String(userId)).setAudience(application.id).setIssuedAt().setExpirationTime(`${options.ttl}s`).setJti(crypto.randomUUID()).sign(await getSigningKey());
5
+ }
6
+ export async function signAccessToken(input) {
7
+ const claims = await buildClaims({ ...input, tokenType: "access_token" });
8
+ return sign(input.event, input.application, input.user.id, {
9
+ ...claims,
10
+ scope: input.scope.join(" "),
11
+ client_id: input.application.id
12
+ }, { typ: "at+jwt", ttl: useOAuthConfig().accessTokenTtl });
13
+ }
14
+ export async function signIdToken(input) {
15
+ const claims = await buildClaims({ ...input, tokenType: "id_token" });
16
+ return sign(input.event, input.application, input.user.id, {
17
+ ...claims,
18
+ auth_time: Math.floor(input.authTime.getTime() / 1e3),
19
+ ...input.nonce ? { nonce: input.nonce } : {}
20
+ }, { typ: "JWT", ttl: useOAuthConfig().accessTokenTtl });
21
+ }
22
+ export async function verifyAccessToken(event, token) {
23
+ try {
24
+ const { payload } = await jwtVerify(token, createLocalJWKSet(await getJwks()), {
25
+ issuer: getOAuthIssuer(event),
26
+ algorithms: ["RS256"],
27
+ typ: "at+jwt"
28
+ });
29
+ if (typeof payload.sub !== "string" || typeof payload.client_id !== "string") return null;
30
+ return payload;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,24 @@
1
+ export type * from '../../types';
2
+ export declare const DEFAULT_SCOPES: readonly ["openid", "profile", "email"];
3
+ export declare const DEFAULT_CALLBACK_PATH = "/auth/frigear";
4
+ /** Whether a user holding `roles` may use an application restricted to `allowedRoles`. Empty = everyone. */
5
+ export declare function matchesAllowedRoles(allowedRoles: readonly string[] | null | undefined, roles: readonly string[]): boolean;
6
+ /** Splits a space separated scope string and drops duplicates and blanks. */
7
+ export declare function parseScope(scope: string | null | undefined): string[];
8
+ /** The requested scopes that the server supports; falls back to the defaults when none were requested. */
9
+ export declare function resolveScope(requested: string | null | undefined, supported: readonly string[]): string[];
10
+ /** HTTPS is required, except for `localhost` / loopback development hosts. */
11
+ export declare function isSecureOrigin(url: URL): boolean;
12
+ export declare function normalizeWebsiteUrl(value: string): string;
13
+ /** `https://*.vercel.app` -> a regex matching one host label in place of `*`. Everything else is matched literally. */
14
+ export declare function previewPatternToRegExp(pattern: string, callbackPath: string): RegExp;
15
+ export type RedirectUriRules = {
16
+ websiteUrl: string;
17
+ previewUrlPattern?: string | null;
18
+ redirectUris?: readonly string[] | null;
19
+ callbackPath?: string;
20
+ };
21
+ /** Exact match on `websiteUrl + callbackPath`, an explicit extra redirect URI, or the preview pattern. */
22
+ export declare function validateRedirectUri(redirectUri: string, rules: RedirectUriRules): boolean;
23
+ /** Prefixed random id, e.g. `newId('app')` -> `app_3f9c…`. */
24
+ export declare function newId(prefix: string): string;