@viktorbarzin/auth 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # @viktorbarzin/auth
2
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.
3
+ App-side sign-in against Authentik, built on Better Auth 1.7:
4
+
5
+ - generic OAuth with PKCE and a verified id_token, sessions in the app's own
6
+ database (Drizzle adapter), host-only `__Host-` cookies, exact trusted
7
+ origins, a 7-day absolute session cap, no `offline_access`, and a single
8
+ `prompt=login` retry when the provider answers with its anonymous guest;
9
+ - caller resolution: a bearer JWT, else the session cookie, else a dev user;
10
+ - a membership re-check against the Authentik API every 15 minutes, which
11
+ fails closed when the app cannot ask and keeps the last answer when the
12
+ provider is unreachable;
13
+ - request and approve sign-up, the explicit CSRF origin check, and identity
14
+ metrics.
15
+
16
+ <!-- typecheck-preamble
17
+ import type { AuthDb } from "@viktorbarzin/auth";
18
+ declare const pool: { getDb(): AuthDb };
19
+ declare const env: Record<string, string | undefined>;
20
+ declare const discoveryUrl: string, clientId: string, clientSecret: string;
21
+ declare const apiUrl: string | null, readerToken: string | null, approverToken: string | null, usersGroupPk: string | null;
22
+ declare const isDevOrTest: boolean;
23
+ -->
24
+ ```ts
25
+ import { createAppAuth } from "@viktorbarzin/auth";
26
+
27
+ export const appAuth = createAppAuth(() => ({
28
+ appName: "My App",
29
+ appSlug: "myapp",
30
+ db: () => pool.getDb(),
31
+ publicOrigins: ["https://myapp.example"],
32
+ betterAuthSecret: env.BETTER_AUTH_SECRET ?? null,
33
+ oidc: { discoveryUrl, clientId, clientSecret },
34
+ authentik: { apiUrl, readerToken, approverToken, usersGroupPk },
35
+ groups: { users: "My App Users", admins: "My App Admins", agents: "My App Agents" },
36
+ signup: "request",
37
+ // Honoured only while `oidc` is null; pass it only in development and test.
38
+ devUser: isDevOrTest ? (env.DEV_USER ?? null) : null,
39
+ }));
40
+
41
+ // routes: <basePath>/start → appAuth.startSignIn, <basePath>/finish → appAuth.finishSignIn,
42
+ // <basePath>/$ → appAuth.handleAuthRequest
43
+ // middleware: appAuth.csrfRejection(request) ?? next()
44
+ // /api routes: appAuth.handleApi(request, async (user) => Response.json(...))
45
+ ```
46
+
47
+ The config function is read on every call; the Better Auth instance is
48
+ rebuilt only when its settings change. Every name, URL, group, path, cookie
49
+ prefix, timing and metric prefix is a config input (see `AppAuthConfig`).
50
+
51
+ | Export | |
52
+ |---|---|
53
+ | `createAppAuth(config)` | `startSignIn`, `finishSignIn`, `handleAuthRequest`, `signInRedirect`, `startUrl`, `resolveUser`, `requireUser` (401 `unauthorized`, 403 `access_pending`), `handleApi`, `accessStatus`, `requireAdmin`, `listPendingRequests`, `approveRequest` (404 / 409 / 502), `currentMembership`, `csrfRejection`, `getAuth` |
54
+ | `enrich(db, { sub, username })` (config) | extra per-user fields, looked up on each resolved request and once at sign-in |
55
+ | `safeReturnPath`, `isSameOriginRequest`, `needsOriginCheck`, `membershipFromGroups` | the pure checks |
56
+ | `createAuthentikClient`, `AuthentikUnavailable`, `AuthentikNotConfigured` | the three Authentik API calls |
57
+ | metrics (prefix `p`, default `auth`) | `p_membership_checks_total{outcome}`, `p_api_token_verifications_total{outcome}`, `p_csrf_rejections_total`, `p_sign_ins_total{outcome}` |
58
+ | `@viktorbarzin/auth/schema` | `auth_user`, `auth_session`, `auth_account`, `auth_verification`, `member_checks`, `access_requests` for the app's Drizzle schema |
59
+ | `@viktorbarzin/auth/testing` | `startMockAuthentik` (OIDC provider plus the API calls), `startBuiltServer`, `CookieJar`, `follow` |
60
+
61
+ A schema change is a major version: apps regenerate their migrations.
62
+ Peers: `better-auth` >=1.7.7 <1.8 (the callback path changed in 1.7),
63
+ `drizzle-orm` ^0.45, `prom-client` ^15.1 and the other `@viktorbarzin`
64
+ packages.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * App-side sign-in against Authentik (ADR-0001, Identity and access):
3
+ * Better Auth's generic OAuth with PKCE and a verified id_token, sessions in
4
+ * the app's database (Drizzle adapter), host-only `__Host-` cookies, exact
5
+ * trustedOrigins, sessions capped at 7 days and never extended, and no
6
+ * `offline_access`. Plus bearer tokens for non-browser clients, the 15-minute
7
+ * membership re-check, request + approve, and the CSRF origin check.
8
+ *
9
+ * Sign-in goes through `<basePath>/start` and `<basePath>/finish`, not Better
10
+ * Auth's own /sign-in/social over HTTP. Everything here takes a Request and
11
+ * returns a Response or data; the app wires it into its framework's routes
12
+ * and middleware.
13
+ */
14
+ import { type Logger } from "@viktorbarzin/log";
15
+ import { type GenericOAuthUserInfo } from "better-auth/plugins/generic-oauth";
16
+ import { type AccessStatus, type AppAuthConfig, type AuthDb, type AuthUser, type PendingRequestView } from "./config.js";
17
+ import { type Membership, type MembershipOptions } from "./membership.js";
18
+ /** Better Auth endpoints an app does not use; refused over HTTP (404). */
19
+ export declare const DISABLED_PATHS: readonly string[];
20
+ /** The config with every default filled in. */
21
+ declare function resolve<TExtra extends object>(config: AppAuthConfig<TExtra>): {
22
+ publicOrigins: string[];
23
+ providerId: string;
24
+ basePath: string;
25
+ scopes: string[];
26
+ sessionMaxAgeSeconds: number;
27
+ cookiePrefix: string;
28
+ clientIpHeader: string;
29
+ apiTokenAudiences: string[];
30
+ guestUsername: string;
31
+ membershipMaxAgeMs: number;
32
+ adminRecheckMaxAgeMs: number;
33
+ devUser: string | null;
34
+ streamPaths: string[];
35
+ loggerPrefix: string;
36
+ appName: string;
37
+ appSlug: string;
38
+ db: () => AuthDb;
39
+ betterAuthSecret: string | null;
40
+ oidc: import("./config.js").OidcClientSettings | null;
41
+ authentik: import("./config.js").AuthentikSettings;
42
+ groups: import("./config.js").GroupNames;
43
+ signup: "request" | "open";
44
+ enrich?: ((db: AuthDb, who: {
45
+ sub: string | null;
46
+ username: string;
47
+ }) => Promise<TExtra>) | undefined;
48
+ metrics?: {
49
+ registry?: import("prom-client").Registry;
50
+ prefix?: string;
51
+ };
52
+ logger?: Logger;
53
+ };
54
+ type Resolved<TExtra extends object> = ReturnType<typeof resolve<TExtra>>;
55
+ declare function buildBetterAuth<TExtra extends object>(cfg: Resolved<TExtra>, oidc: NonNullable<AppAuthConfig["oidc"]>, secret: string, onVerifiedProfile: (profile: GenericOAuthUserInfo) => Promise<{
56
+ name: string;
57
+ email: string;
58
+ emailVerified: boolean;
59
+ image: undefined;
60
+ }>): import("better-auth").Auth<{
61
+ appName: string;
62
+ baseURL: string;
63
+ basePath: string;
64
+ secret: string;
65
+ trustedOrigins: string[];
66
+ database: (options: import("better-auth").BetterAuthOptions) => import("better-auth").DBAdapter<import("better-auth").BetterAuthOptions>;
67
+ user: {
68
+ modelName: "auth_user";
69
+ };
70
+ session: {
71
+ modelName: "auth_session";
72
+ expiresIn: number;
73
+ disableSessionRefresh: true;
74
+ cookieCache: {
75
+ enabled: false;
76
+ };
77
+ };
78
+ account: {
79
+ modelName: "auth_account";
80
+ accountLinking: {
81
+ enabled: false;
82
+ };
83
+ encryptOAuthTokens: true;
84
+ };
85
+ verification: {
86
+ modelName: "auth_verification";
87
+ };
88
+ emailAndPassword: {
89
+ enabled: false;
90
+ };
91
+ disabledPaths: string[];
92
+ telemetry: {
93
+ enabled: false;
94
+ };
95
+ advanced: {
96
+ ipAddress: {
97
+ ipAddressHeaders: string[];
98
+ };
99
+ cookiePrefix: string;
100
+ useSecureCookies: false;
101
+ defaultCookieAttributes: {
102
+ secure: true;
103
+ sameSite: "lax";
104
+ path: string;
105
+ httpOnly: true;
106
+ };
107
+ };
108
+ plugins: [{
109
+ id: "generic-oauth";
110
+ version: string;
111
+ init: (ctx: import("better-auth").AuthContext) => Promise<{
112
+ context: {
113
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
114
+ };
115
+ }>;
116
+ options: import("better-auth/plugins/generic-oauth").GenericOAuthOptions<string>;
117
+ $ERROR_CODES: {
118
+ INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
119
+ TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
120
+ };
121
+ }];
122
+ }>;
123
+ export type BetterAuthInstance = ReturnType<typeof buildBetterAuth>;
124
+ export interface AppAuth<TExtra extends object> {
125
+ /** The Better Auth instance, or null when sign-in is not configured. */
126
+ getAuth(): BetterAuthInstance | null;
127
+ /** GET <basePath>/start?return=<path>[&login=1]: redirect to the provider. 503 auth_unavailable when it cannot. */
128
+ startSignIn(request: Request): Promise<Response>;
129
+ /** GET <basePath>/finish?return=<path>: Better Auth set the session cookie; go back. */
130
+ finishSignIn(request: Request): Response;
131
+ /** Everything else under <basePath>: callback, sign-out, get-session, error page. */
132
+ handleAuthRequest(request: Request): Promise<Response>;
133
+ /** A redirect into sign-in that comes back to `returnTo`; null when sign-in is off. */
134
+ signInRedirect(returnTo: string): Response | null;
135
+ startUrl(returnTo: string, forceLogin: boolean): string;
136
+ /** The caller, or null. A refused bearer token is null even with a cookie. Membership is not enforced. */
137
+ resolveUser(request: Request): Promise<(AuthUser & TExtra) | null>;
138
+ /** The caller, or ApiError 401 unauthorized / 403 access_pending. */
139
+ requireUser(request: Request): Promise<AuthUser & TExtra>;
140
+ /** Resolve the caller, run `fn`, map errors to the envelope. */
141
+ handleApi(request: Request, fn: (user: AuthUser & TExtra) => Promise<Response>, toError?: (err: unknown, request: Request) => Response): Promise<Response>;
142
+ accessStatus(request: Request, opts?: {
143
+ recheck?: boolean;
144
+ }): Promise<AccessStatus>;
145
+ /** Throws ApiError 403 unless the user is an admin right now (re-checked within adminRecheckMaxAgeMs). */
146
+ requireAdmin(user: AuthUser): Promise<void>;
147
+ listPendingRequests(admin: AuthUser): Promise<PendingRequestView[]>;
148
+ /** 404 not_found / 409 conflict / 502 authentik_unavailable. */
149
+ approveRequest(admin: AuthUser, sub: string): Promise<void>;
150
+ currentMembership(sub: string, username: string, opts?: MembershipOptions): Promise<Membership>;
151
+ /** The 403 envelope for a refused cross-origin request, or null to let it through. */
152
+ csrfRejection(request: Request): Response | null;
153
+ }
154
+ /**
155
+ * The app's identity layer. `config` is re-read on every call (cheap; the app
156
+ * builds it from the environment); the Better Auth instance is rebuilt only
157
+ * when its settings change.
158
+ */
159
+ export declare function createAppAuth<TExtra extends object = Record<never, never>>(config: () => AppAuthConfig<TExtra>): AppAuth<TExtra>;
160
+ export {};