@evcraddock/slug-auth 0.1.0
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 +20 -0
- package/dist/index.d.ts +172 -0
- package/dist/index.js +299 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @evcraddock/slug-auth
|
|
2
|
+
|
|
3
|
+
Reusable Slugkit authentication primitives and storage interfaces.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- Token helpers: `generateToken`, `hashToken`, `generateRawApiKey`, `hashApiKey`, and `readApiKeyPrefix`.
|
|
8
|
+
- API key helpers: `VIEWER_API_KEY_SCOPES`, `normalizeApiKeyScopes`, `isApiKeyAuthorizedForOperation`, and `operationToScope`.
|
|
9
|
+
- Magic-link helpers: `normalizeEmail`, `normalizeRedirectPath`, `createLoginUrl`, `createMagicLinkDelivery`, `readSmtpConfig`, and `createMagicLinkMessage`.
|
|
10
|
+
- Passkey helpers: `resolveWebAuthnOrigin`, `createRelyingPartyFromOrigins`, `normalizePasskeyName`, `normalizeTransports`, `encodeBase64Url`, `decodeBase64Url`, and `parseStringArrayJson`.
|
|
11
|
+
- Logging helpers: `consoleAuthLogger`, `consoleApiLogger`, `silentAuthLogger`, and `logCaughtError`.
|
|
12
|
+
- Storage interfaces: `ApiKeyRepository`, `SiteUserRepository`, `SessionRepository`, `MagicLinkRepository`, and `PasskeyRepository`.
|
|
13
|
+
|
|
14
|
+
## Site-owned storage
|
|
15
|
+
|
|
16
|
+
This package intentionally does not own generated-site databases. Generated sites keep local wrapper modules and migrations, then use package helpers for the security-sensitive primitives that should receive patch updates.
|
|
17
|
+
|
|
18
|
+
## Security patch guidance
|
|
19
|
+
|
|
20
|
+
Generated sites should apply patch releases of `@evcraddock/slug-auth` promptly. Patch releases may include token handling, API-key verification, SMTP delivery, passkey/WebAuthn, and logging hardening fixes without requiring a template rewrite.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { SendMailOptions, Transporter } from "nodemailer";
|
|
2
|
+
export type AuthLogLevel = "info" | "warn";
|
|
3
|
+
export interface AuthLogEntry {
|
|
4
|
+
level: AuthLogLevel;
|
|
5
|
+
message: string;
|
|
6
|
+
context?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export type AuthLogger = (entry: AuthLogEntry) => void;
|
|
9
|
+
export type SiteUserRole = "admin" | "viewer";
|
|
10
|
+
export interface SiteUserRecord {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
displayName: string | null;
|
|
14
|
+
roles: SiteUserRole[];
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
deactivatedAt: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface AuthSessionRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
email: string;
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const VIEWER_API_KEY_SCOPES: readonly ["posts:read", "sources:read", "contacts:read"];
|
|
26
|
+
export type ApiKeyScope = (typeof VIEWER_API_KEY_SCOPES)[number];
|
|
27
|
+
export interface ApiKeyRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
keyPrefix: string;
|
|
31
|
+
scopes: ApiKeyScope[];
|
|
32
|
+
siteUserId: string | null;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
lastUsedAt: string | null;
|
|
35
|
+
revokedAt: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface CreatedApiKey {
|
|
38
|
+
apiKey: ApiKeyRecord;
|
|
39
|
+
rawKey: string;
|
|
40
|
+
}
|
|
41
|
+
export interface VerifiedApiKey {
|
|
42
|
+
valid: true;
|
|
43
|
+
apiKey: ApiKeyRecord;
|
|
44
|
+
}
|
|
45
|
+
export interface InvalidApiKey {
|
|
46
|
+
valid: false;
|
|
47
|
+
}
|
|
48
|
+
export type ApiKeyVerificationResult = VerifiedApiKey | InvalidApiKey;
|
|
49
|
+
export type ApiKeyValidator = (rawKey: string, operation?: string, method?: string) => boolean | Promise<boolean>;
|
|
50
|
+
export interface ApiKeyRepository {
|
|
51
|
+
create(input: {
|
|
52
|
+
name: string;
|
|
53
|
+
siteUserId: string | null;
|
|
54
|
+
scopes: ApiKeyScope[];
|
|
55
|
+
}): CreatedApiKey;
|
|
56
|
+
list(input?: {
|
|
57
|
+
siteUserId?: string;
|
|
58
|
+
}): ApiKeyRecord[];
|
|
59
|
+
verify(rawKey: string): ApiKeyVerificationResult;
|
|
60
|
+
revoke(id: string, input?: {
|
|
61
|
+
siteUserId?: string;
|
|
62
|
+
}): boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface SiteUserRepository {
|
|
65
|
+
list(): SiteUserRecord[];
|
|
66
|
+
getByEmail(email: string): SiteUserRecord | null;
|
|
67
|
+
isActive(email: string): boolean;
|
|
68
|
+
hasRole(email: string, role: SiteUserRole): boolean;
|
|
69
|
+
}
|
|
70
|
+
export interface SessionRepository {
|
|
71
|
+
create(email: string, now?: Date): AuthSessionRecord;
|
|
72
|
+
get(sessionId: string | undefined, now?: Date): AuthSessionRecord | null;
|
|
73
|
+
delete(sessionId: string | undefined): void;
|
|
74
|
+
}
|
|
75
|
+
export interface MagicLinkRepository {
|
|
76
|
+
create(input: {
|
|
77
|
+
id: string;
|
|
78
|
+
email: string;
|
|
79
|
+
tokenHash: string;
|
|
80
|
+
redirectPath: string | null;
|
|
81
|
+
expiresAt: string;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
}): void;
|
|
84
|
+
verify(input: {
|
|
85
|
+
tokenHash: string;
|
|
86
|
+
now: Date;
|
|
87
|
+
}): MagicLinkVerificationResult;
|
|
88
|
+
}
|
|
89
|
+
export interface PasskeyRepository {
|
|
90
|
+
list(email?: string): AdminPasskeyRecord[];
|
|
91
|
+
getByCredentialId(credentialId: string): AdminPasskeyRecord | null;
|
|
92
|
+
delete(id: string, email: string): void;
|
|
93
|
+
}
|
|
94
|
+
export interface AdminPasskeyRecord {
|
|
95
|
+
id: string;
|
|
96
|
+
email: string;
|
|
97
|
+
name: string;
|
|
98
|
+
credentialId: string;
|
|
99
|
+
credentialPublicKey: string;
|
|
100
|
+
counter: number;
|
|
101
|
+
transports: string[];
|
|
102
|
+
credentialDeviceType: string;
|
|
103
|
+
credentialBackedUp: boolean;
|
|
104
|
+
aaguid: string | null;
|
|
105
|
+
createdAt: string;
|
|
106
|
+
lastUsedAt: string | null;
|
|
107
|
+
}
|
|
108
|
+
export interface MagicLinkVerificationSuccess {
|
|
109
|
+
valid: true;
|
|
110
|
+
email: string;
|
|
111
|
+
redirectPath: string | null;
|
|
112
|
+
}
|
|
113
|
+
export interface MagicLinkVerificationFailure {
|
|
114
|
+
valid: false;
|
|
115
|
+
}
|
|
116
|
+
export type MagicLinkVerificationResult = MagicLinkVerificationSuccess | MagicLinkVerificationFailure;
|
|
117
|
+
export interface MagicLinkDeliveryInput {
|
|
118
|
+
email: string;
|
|
119
|
+
loginUrl: string;
|
|
120
|
+
}
|
|
121
|
+
export type MagicLinkDelivery = (input: MagicLinkDeliveryInput) => Promise<void> | void;
|
|
122
|
+
export interface SmtpConfig {
|
|
123
|
+
host: string;
|
|
124
|
+
port: number;
|
|
125
|
+
secure: boolean;
|
|
126
|
+
auth: {
|
|
127
|
+
user: string;
|
|
128
|
+
pass: string;
|
|
129
|
+
} | undefined;
|
|
130
|
+
fromEmail: string;
|
|
131
|
+
fromName: string | undefined;
|
|
132
|
+
}
|
|
133
|
+
export interface CreateMagicLinkDeliveryOptions {
|
|
134
|
+
createTransport?: (config: SmtpConfig) => Pick<Transporter, "sendMail">;
|
|
135
|
+
}
|
|
136
|
+
export declare class MagicLinkDeliveryNotConfiguredError extends Error {
|
|
137
|
+
constructor(message?: string);
|
|
138
|
+
}
|
|
139
|
+
export declare const API_KEY_DISPLAY_PREFIX_LENGTH = 12;
|
|
140
|
+
export declare function generateToken(byteLength?: number): string;
|
|
141
|
+
export declare function hashToken(token: string): string;
|
|
142
|
+
export declare function generateRawApiKey(): string;
|
|
143
|
+
export declare function hashApiKey(rawKey: string): string;
|
|
144
|
+
export declare function readApiKeyPrefix(rawKey: string): string;
|
|
145
|
+
export declare function normalizeApiKeyScopes(scopes: readonly unknown[]): ApiKeyScope[];
|
|
146
|
+
export declare function isApiKeyScope(value: unknown): value is ApiKeyScope;
|
|
147
|
+
export declare function isApiKeyAuthorizedForOperation(apiKey: Pick<ApiKeyRecord, "scopes">, operation: string | undefined): boolean;
|
|
148
|
+
export declare function operationToScope(operation: string | undefined): ApiKeyScope | null;
|
|
149
|
+
export declare function createLoginUrl(requestUrl: string, token: string, redirectPath: string | null): string;
|
|
150
|
+
export declare function normalizeEmail(email: string): string;
|
|
151
|
+
export declare function normalizeRedirectPath(path: string | undefined): string | null;
|
|
152
|
+
export declare function createMagicLinkDelivery(environment?: Record<string, string | undefined>, options?: CreateMagicLinkDeliveryOptions): MagicLinkDelivery;
|
|
153
|
+
export declare function readSmtpConfig(environment: Record<string, string | undefined>): SmtpConfig;
|
|
154
|
+
export declare function createMagicLinkMessage(config: SmtpConfig, email: string, loginUrl: string): SendMailOptions;
|
|
155
|
+
export declare function resolveWebAuthnOrigin(configuredSiteUrl: string, requestUrl: string): string;
|
|
156
|
+
export declare function createRelyingPartyFromOrigins(configuredSiteUrl: string, requestUrl: string): {
|
|
157
|
+
origin: string;
|
|
158
|
+
rpID: string;
|
|
159
|
+
};
|
|
160
|
+
export declare function normalizePasskeyName(name: string | undefined): string;
|
|
161
|
+
export declare function normalizeTransports(transports: string[]): ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[];
|
|
162
|
+
export declare function encodeBase64Url(value: Uint8Array): string;
|
|
163
|
+
export declare function decodeBase64Url(value: string): Uint8Array<ArrayBuffer>;
|
|
164
|
+
export declare function parseStringArrayJson(value: string): string[];
|
|
165
|
+
export declare const consoleAuthLogger: AuthLogger;
|
|
166
|
+
export declare const consoleApiLogger: AuthLogger;
|
|
167
|
+
export declare const silentAuthLogger: AuthLogger;
|
|
168
|
+
export declare function logCaughtError(logger: AuthLogger, input: {
|
|
169
|
+
message: string;
|
|
170
|
+
error: unknown;
|
|
171
|
+
context?: Record<string, string | undefined>;
|
|
172
|
+
}): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import nodemailer from "nodemailer";
|
|
3
|
+
export const VIEWER_API_KEY_SCOPES = ["posts:read", "sources:read", "contacts:read"];
|
|
4
|
+
export class MagicLinkDeliveryNotConfiguredError extends Error {
|
|
5
|
+
constructor(message = "Magic link delivery is not configured") {
|
|
6
|
+
super(message);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const RAW_KEY_PREFIX = "slug_";
|
|
10
|
+
const RAW_KEY_RANDOM_BYTES = 32;
|
|
11
|
+
export const API_KEY_DISPLAY_PREFIX_LENGTH = 12;
|
|
12
|
+
export function generateToken(byteLength = 32) {
|
|
13
|
+
return randomBytes(byteLength).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
export function hashToken(token) {
|
|
16
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
17
|
+
}
|
|
18
|
+
export function generateRawApiKey() {
|
|
19
|
+
return `${RAW_KEY_PREFIX}${randomBytes(RAW_KEY_RANDOM_BYTES).toString("base64url")}`;
|
|
20
|
+
}
|
|
21
|
+
export function hashApiKey(rawKey) {
|
|
22
|
+
return hashToken(rawKey);
|
|
23
|
+
}
|
|
24
|
+
export function readApiKeyPrefix(rawKey) {
|
|
25
|
+
return rawKey.slice(0, API_KEY_DISPLAY_PREFIX_LENGTH);
|
|
26
|
+
}
|
|
27
|
+
export function normalizeApiKeyScopes(scopes) {
|
|
28
|
+
const normalized = [];
|
|
29
|
+
for (const scope of scopes) {
|
|
30
|
+
if (isApiKeyScope(scope) && !normalized.includes(scope))
|
|
31
|
+
normalized.push(scope);
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
export function isApiKeyScope(value) {
|
|
36
|
+
return typeof value === "string" && VIEWER_API_KEY_SCOPES.includes(value);
|
|
37
|
+
}
|
|
38
|
+
export function isApiKeyAuthorizedForOperation(apiKey, operation) {
|
|
39
|
+
if (apiKey.scopes.length === 0)
|
|
40
|
+
return true;
|
|
41
|
+
const requiredScope = operationToScope(operation);
|
|
42
|
+
return requiredScope !== null && apiKey.scopes.includes(requiredScope);
|
|
43
|
+
}
|
|
44
|
+
export function operationToScope(operation) {
|
|
45
|
+
switch (operation) {
|
|
46
|
+
case "posts.list":
|
|
47
|
+
case "posts.get":
|
|
48
|
+
return "posts:read";
|
|
49
|
+
case "sources.list":
|
|
50
|
+
case "sources.get":
|
|
51
|
+
return "sources:read";
|
|
52
|
+
case "contacts.list":
|
|
53
|
+
case "contacts.get":
|
|
54
|
+
return "contacts:read";
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function createLoginUrl(requestUrl, token, redirectPath) {
|
|
60
|
+
const url = new URL("/login/verify", requestUrl);
|
|
61
|
+
url.searchParams.set("token", token);
|
|
62
|
+
if (redirectPath !== null) {
|
|
63
|
+
url.searchParams.set("redirect", redirectPath);
|
|
64
|
+
}
|
|
65
|
+
return url.toString();
|
|
66
|
+
}
|
|
67
|
+
export function normalizeEmail(email) {
|
|
68
|
+
return email.trim().toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
export function normalizeRedirectPath(path) {
|
|
71
|
+
if (path === undefined || !path.startsWith("/") || path.startsWith("//")) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
export function createMagicLinkDelivery(environment = process.env, options = {}) {
|
|
77
|
+
const devMode = isDevDeliveryMode(environment);
|
|
78
|
+
if (devMode) {
|
|
79
|
+
return ({ email, loginUrl }) => {
|
|
80
|
+
console.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
81
|
+
console.info(`Magic link for ${email}:`);
|
|
82
|
+
console.info(loginUrl);
|
|
83
|
+
console.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const config = readSmtpConfig(environment);
|
|
87
|
+
const transport = options.createTransport?.(config) ?? createNodemailerTransport(config);
|
|
88
|
+
return async ({ email, loginUrl }) => {
|
|
89
|
+
const message = createMagicLinkMessage(config, email, loginUrl);
|
|
90
|
+
await transport.sendMail(message);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function readSmtpConfig(environment) {
|
|
94
|
+
const host = readRequiredEnv(environment, "SMTP_HOST");
|
|
95
|
+
const port = readPort(environment.SMTP_PORT);
|
|
96
|
+
const username = readOptionalEnv(environment.SMTP_USERNAME);
|
|
97
|
+
const password = readOptionalEnv(environment.SMTP_PASSWORD);
|
|
98
|
+
const fromEmail = readRequiredEnv(environment, "SMTP_FROM_EMAIL");
|
|
99
|
+
const fromName = readOptionalEnv(environment.SMTP_FROM_NAME);
|
|
100
|
+
const secure = readBooleanEnv(environment.SMTP_SECURE, port === 465, "SMTP_SECURE");
|
|
101
|
+
if ((username === undefined) !== (password === undefined)) {
|
|
102
|
+
throw new MagicLinkDeliveryNotConfiguredError("SMTP_USERNAME and SMTP_PASSWORD must be configured together");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
host,
|
|
106
|
+
port,
|
|
107
|
+
secure,
|
|
108
|
+
auth: username === undefined ? undefined : { user: username, pass: password ?? "" },
|
|
109
|
+
fromEmail,
|
|
110
|
+
fromName,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function createMagicLinkMessage(config, email, loginUrl) {
|
|
114
|
+
const from = formatSender(config);
|
|
115
|
+
const subject = "Your Slugkit sign-in link";
|
|
116
|
+
const text = [
|
|
117
|
+
"Use this link to sign in to your Slugkit site:",
|
|
118
|
+
"",
|
|
119
|
+
loginUrl,
|
|
120
|
+
"",
|
|
121
|
+
"This link expires in 15 minutes. If you did not request it, you can ignore this email.",
|
|
122
|
+
].join("\n");
|
|
123
|
+
const html = `<p>Use this link to sign in to your Slugkit site:</p><p><a href="${escapeHtml(loginUrl)}">Sign in</a></p><p>This link expires in 15 minutes. If you did not request it, you can ignore this email.</p>`;
|
|
124
|
+
return { from, to: email, subject, text, html };
|
|
125
|
+
}
|
|
126
|
+
export function resolveWebAuthnOrigin(configuredSiteUrl, requestUrl) {
|
|
127
|
+
const requestOrigin = new URL(requestUrl).origin;
|
|
128
|
+
const configuredOrigin = readConfiguredWebAuthnOrigin(configuredSiteUrl);
|
|
129
|
+
if (configuredOrigin === null)
|
|
130
|
+
return requestOrigin;
|
|
131
|
+
if (isLocalhostOrigin(configuredOrigin)) {
|
|
132
|
+
return isLocalhostOrigin(requestOrigin) ? configuredOrigin : requestOrigin;
|
|
133
|
+
}
|
|
134
|
+
return configuredOrigin;
|
|
135
|
+
}
|
|
136
|
+
export function createRelyingPartyFromOrigins(configuredSiteUrl, requestUrl) {
|
|
137
|
+
const origin = resolveWebAuthnOrigin(configuredSiteUrl, requestUrl);
|
|
138
|
+
const url = new URL(origin);
|
|
139
|
+
return { origin, rpID: url.hostname };
|
|
140
|
+
}
|
|
141
|
+
export function normalizePasskeyName(name) {
|
|
142
|
+
const trimmed = name?.trim() ?? "";
|
|
143
|
+
return trimmed === "" ? "Passkey" : trimmed;
|
|
144
|
+
}
|
|
145
|
+
export function normalizeTransports(transports) {
|
|
146
|
+
return transports.filter((transport) => ["ble", "cable", "hybrid", "internal", "nfc", "smart-card", "usb"].includes(transport));
|
|
147
|
+
}
|
|
148
|
+
export function encodeBase64Url(value) {
|
|
149
|
+
return Buffer.from(value).toString("base64url");
|
|
150
|
+
}
|
|
151
|
+
export function decodeBase64Url(value) {
|
|
152
|
+
const buffer = Buffer.from(value, "base64url");
|
|
153
|
+
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
154
|
+
return new Uint8Array(arrayBuffer);
|
|
155
|
+
}
|
|
156
|
+
export function parseStringArrayJson(value) {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(value);
|
|
159
|
+
return Array.isArray(parsed)
|
|
160
|
+
? parsed.filter((item) => typeof item === "string")
|
|
161
|
+
: [];
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export const consoleAuthLogger = (entry) => {
|
|
168
|
+
writeConsoleLog("slugkit auth", entry);
|
|
169
|
+
};
|
|
170
|
+
export const consoleApiLogger = (entry) => {
|
|
171
|
+
writeConsoleLog("slugkit api", entry);
|
|
172
|
+
};
|
|
173
|
+
export const silentAuthLogger = () => { };
|
|
174
|
+
export function logCaughtError(logger, input) {
|
|
175
|
+
const errorContext = serializeCaughtError(input.error);
|
|
176
|
+
logger({
|
|
177
|
+
level: "warn",
|
|
178
|
+
message: input.message,
|
|
179
|
+
context: {
|
|
180
|
+
...filterLogContext(input.context),
|
|
181
|
+
...errorContext,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function isDevDeliveryMode(environment) {
|
|
186
|
+
if (environment.AUTH_DEV_MODE === "true")
|
|
187
|
+
return true;
|
|
188
|
+
if (environment.AUTH_DEV_MODE === "false")
|
|
189
|
+
return false;
|
|
190
|
+
return environment.NODE_ENV !== "production";
|
|
191
|
+
}
|
|
192
|
+
function createNodemailerTransport(config) {
|
|
193
|
+
return nodemailer.createTransport({
|
|
194
|
+
host: config.host,
|
|
195
|
+
port: config.port,
|
|
196
|
+
secure: config.secure,
|
|
197
|
+
auth: config.auth,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function formatSender(config) {
|
|
201
|
+
if (config.fromName === undefined)
|
|
202
|
+
return config.fromEmail;
|
|
203
|
+
return `${quoteDisplayName(config.fromName)} <${config.fromEmail}>`;
|
|
204
|
+
}
|
|
205
|
+
function quoteDisplayName(value) {
|
|
206
|
+
return `"${value.replace(/["\\]/gu, "\\$&")}"`;
|
|
207
|
+
}
|
|
208
|
+
function readRequiredEnv(environment, name) {
|
|
209
|
+
const value = readOptionalEnv(environment[name]);
|
|
210
|
+
if (value === undefined) {
|
|
211
|
+
throw new MagicLinkDeliveryNotConfiguredError(`${name} is required when AUTH_DEV_MODE is false`);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function readOptionalEnv(value) {
|
|
216
|
+
if (value === undefined || value.trim() === "")
|
|
217
|
+
return undefined;
|
|
218
|
+
return value.trim();
|
|
219
|
+
}
|
|
220
|
+
function readPort(value) {
|
|
221
|
+
const portText = readOptionalEnv(value);
|
|
222
|
+
if (portText === undefined)
|
|
223
|
+
return 587;
|
|
224
|
+
const port = Number(portText);
|
|
225
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
226
|
+
throw new MagicLinkDeliveryNotConfiguredError("SMTP_PORT must be a valid TCP port");
|
|
227
|
+
}
|
|
228
|
+
return port;
|
|
229
|
+
}
|
|
230
|
+
function readBooleanEnv(value, defaultValue, name) {
|
|
231
|
+
const normalized = readOptionalEnv(value)?.toLowerCase();
|
|
232
|
+
if (normalized === undefined)
|
|
233
|
+
return defaultValue;
|
|
234
|
+
if (["true", "1", "yes", "on"].includes(normalized))
|
|
235
|
+
return true;
|
|
236
|
+
if (["false", "0", "no", "off"].includes(normalized))
|
|
237
|
+
return false;
|
|
238
|
+
throw new MagicLinkDeliveryNotConfiguredError(`${name} must be true or false`);
|
|
239
|
+
}
|
|
240
|
+
function escapeHtml(value) {
|
|
241
|
+
return value.replace(/[&<>"]/gu, (character) => {
|
|
242
|
+
switch (character) {
|
|
243
|
+
case "&":
|
|
244
|
+
return "&";
|
|
245
|
+
case "<":
|
|
246
|
+
return "<";
|
|
247
|
+
case ">":
|
|
248
|
+
return ">";
|
|
249
|
+
case '"':
|
|
250
|
+
return """;
|
|
251
|
+
default:
|
|
252
|
+
return character;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function readConfiguredWebAuthnOrigin(configuredSiteUrl) {
|
|
257
|
+
try {
|
|
258
|
+
const url = new URL(configuredSiteUrl);
|
|
259
|
+
if (url.protocol !== "https:")
|
|
260
|
+
return null;
|
|
261
|
+
return url.origin;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function isLocalhostOrigin(origin) {
|
|
268
|
+
try {
|
|
269
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
270
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function writeConsoleLog(prefix, entry) {
|
|
277
|
+
const context = entry.context === undefined ? "" : ` ${JSON.stringify(entry.context)}`;
|
|
278
|
+
const line = `${prefix}: ${entry.message}${context}`;
|
|
279
|
+
if (entry.level === "warn") {
|
|
280
|
+
console.warn(line);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.info(line);
|
|
284
|
+
}
|
|
285
|
+
function serializeCaughtError(error) {
|
|
286
|
+
if (error instanceof Error) {
|
|
287
|
+
return {
|
|
288
|
+
errorName: error.name,
|
|
289
|
+
errorMessage: error.message,
|
|
290
|
+
errorStack: error.stack ?? error.message,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return { errorMessage: String(error) };
|
|
294
|
+
}
|
|
295
|
+
function filterLogContext(context) {
|
|
296
|
+
if (context === undefined)
|
|
297
|
+
return {};
|
|
298
|
+
return Object.fromEntries(Object.entries(context).filter((entry) => entry[1] !== undefined));
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evcraddock/slug-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable Slugkit authentication primitives and interfaces.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
25
|
+
"test": "vitest run --config ../../vitest.config.ts src",
|
|
26
|
+
"test:watch": "vitest --config ../../vitest.config.ts src"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@simplewebauthn/server": "^13.3.1",
|
|
30
|
+
"nodemailer": "^9.0.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/nodemailer": "^8.0.1",
|
|
34
|
+
"typescript": "5.9.3",
|
|
35
|
+
"vitest": "4.1.6"
|
|
36
|
+
}
|
|
37
|
+
}
|