tauri-plugin-better-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/LICENSE +21 -0
- package/README.md +49 -2
- package/dist/client.d.ts +232 -0
- package/dist/client.js +244 -0
- package/dist/index-DYZxnm2f.d.ts +292 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +235 -0
- package/dist/protocol-Fcr7NzIh.js +111 -0
- package/dist/proxy.d.ts +124 -0
- package/dist/proxy.js +217 -0
- package/package.json +93 -4
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import "zod";
|
|
2
|
+
import { User } from "better-auth";
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/** Options of the {@link tauri} server plugin. */
|
|
5
|
+
interface TauriOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Identifies the desktop app. Must match the Rust plugin and the hand-off
|
|
8
|
+
* page client.
|
|
9
|
+
*
|
|
10
|
+
* @default "tauri"
|
|
11
|
+
*/
|
|
12
|
+
clientID?: string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Seconds an authorization code stays valid. Codes are single-use.
|
|
15
|
+
*
|
|
16
|
+
* @default 300
|
|
17
|
+
*/
|
|
18
|
+
codeExpiresIn?: number | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Origins allowed to call `POST /tauri/authorize`, normally just the origin
|
|
21
|
+
* of your hand-off page. When unset, any origin Better Auth trusts may call
|
|
22
|
+
* it, which widens the attack surface to every trusted site.
|
|
23
|
+
*/
|
|
24
|
+
authorizeOrigins?: string[] | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Rate limit for both `/tauri/*` endpoints, per client IP. Better Auth only
|
|
27
|
+
* enforces rate limits when its own `rateLimit` is enabled (by default in
|
|
28
|
+
* production). Pass `false` to rely on your global rules instead.
|
|
29
|
+
*
|
|
30
|
+
* @default { window: 60, max: 10 }
|
|
31
|
+
*/
|
|
32
|
+
rateLimit?: {
|
|
33
|
+
window: number;
|
|
34
|
+
max: number;
|
|
35
|
+
} | false | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Fully resolved options, exposed as `plugin.options`. */
|
|
38
|
+
interface ResolvedTauriOptions {
|
|
39
|
+
clientID: string;
|
|
40
|
+
codeExpiresIn: number;
|
|
41
|
+
authorizeOrigins: string[] | undefined;
|
|
42
|
+
rateLimit: {
|
|
43
|
+
window: number;
|
|
44
|
+
max: number;
|
|
45
|
+
} | false;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/error-codes.d.ts
|
|
49
|
+
/**
|
|
50
|
+
* Error codes returned by the `/tauri/*` endpoints. Stable within protocol v1:
|
|
51
|
+
* the Rust plugin and apps switch on `code`, never on `message`.
|
|
52
|
+
*/
|
|
53
|
+
declare const TAURI_ERROR_CODES: {
|
|
54
|
+
INVALID_CLIENT_ID: import("better-auth").RawError<"INVALID_CLIENT_ID">;
|
|
55
|
+
INVALID_CODE_CHALLENGE: import("better-auth").RawError<"INVALID_CODE_CHALLENGE">;
|
|
56
|
+
INVALID_CODE_VERIFIER: import("better-auth").RawError<"INVALID_CODE_VERIFIER">;
|
|
57
|
+
INVALID_OR_EXPIRED_CODE: import("better-auth").RawError<"INVALID_OR_EXPIRED_CODE">;
|
|
58
|
+
INVALID_STATE: import("better-auth").RawError<"INVALID_STATE">;
|
|
59
|
+
ORIGIN_NOT_ALLOWED: import("better-auth").RawError<"ORIGIN_NOT_ALLOWED">;
|
|
60
|
+
PKCE_VERIFICATION_FAILED: import("better-auth").RawError<"PKCE_VERIFICATION_FAILED">;
|
|
61
|
+
SESSION_REJECTED: import("better-auth").RawError<"SESSION_REJECTED">;
|
|
62
|
+
STATE_MISMATCH: import("better-auth").RawError<"STATE_MISMATCH">;
|
|
63
|
+
USER_NOT_FOUND: import("better-auth").RawError<"USER_NOT_FOUND">;
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/protocol.d.ts
|
|
67
|
+
/**
|
|
68
|
+
* Wire protocol v1, shared by the server plugin, the web hand-off client and
|
|
69
|
+
* the Rust plugin. Documented in PROTOCOL.md. Within v1, changes are additive
|
|
70
|
+
* only: installed desktop apps lag behind servers and must keep working.
|
|
71
|
+
*
|
|
72
|
+
* This module has no runtime dependencies so every entry point can import it.
|
|
73
|
+
*/
|
|
74
|
+
/** Protocol version sent as `v` on the sign-in URL and returned by `/tauri/token`. */
|
|
75
|
+
declare const PROTOCOL_VERSION = 1;
|
|
76
|
+
/** Thrown when a configuration value breaks a protocol rule. */
|
|
77
|
+
declare class TauriConfigError extends Error {
|
|
78
|
+
name: string;
|
|
79
|
+
}
|
|
80
|
+
/** Returns true for a custom URI scheme the app can register. */
|
|
81
|
+
declare function isValidScheme(scheme: string): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Returns true when `uri` is a loopback redirect the hand-off page may follow:
|
|
84
|
+
* exactly `http://127.0.0.1:<port><callbackPath>` or
|
|
85
|
+
* `http://[::1]:<port><callbackPath>`, with no userinfo, query or fragment.
|
|
86
|
+
*
|
|
87
|
+
* Anything else is rejected so that a crafted sign-in link cannot send an
|
|
88
|
+
* authorization code to a remote server (RFC 8252 §7.3, §8.3).
|
|
89
|
+
*/
|
|
90
|
+
declare function isLoopbackRedirectUri(uri: string, callbackPath: string): boolean;
|
|
91
|
+
/** Where the hand-off page sends the browser after authorizing. */
|
|
92
|
+
type CallbackTarget = {
|
|
93
|
+
kind: 'scheme';
|
|
94
|
+
scheme: string;
|
|
95
|
+
callbackPath: string;
|
|
96
|
+
} | {
|
|
97
|
+
kind: 'loopback';
|
|
98
|
+
redirectUri: string;
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/routes.d.ts
|
|
102
|
+
/** Response body of `POST /tauri/token`. */
|
|
103
|
+
interface TauriTokenResponse {
|
|
104
|
+
/** Session token, used as `Authorization: Bearer` by the app. */
|
|
105
|
+
token: string;
|
|
106
|
+
/** The signed-in user, filtered by `parseUserOutput`. */
|
|
107
|
+
user: User & Record<string, unknown>;
|
|
108
|
+
/** Wire protocol version (see PROTOCOL.md). */
|
|
109
|
+
protocol: typeof PROTOCOL_VERSION;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/version.d.ts
|
|
113
|
+
/**
|
|
114
|
+
* Package version. Released in lockstep with the Rust crate; CI fails when this
|
|
115
|
+
* constant, `package.json` and `Cargo.toml` disagree.
|
|
116
|
+
*/
|
|
117
|
+
declare const VERSION = "0.1.0";
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/index.d.ts
|
|
120
|
+
/** What `init` needs from Better Auth's context. */
|
|
121
|
+
interface InitContext {
|
|
122
|
+
hasPlugin(pluginId: string): boolean;
|
|
123
|
+
logger: {
|
|
124
|
+
warn(message: string, ...args: unknown[]): void;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Better Auth server plugin for Tauri desktop apps.
|
|
129
|
+
*
|
|
130
|
+
* Adds two endpoints:
|
|
131
|
+
* - `POST /tauri/authorize`: called by your hand-off web page for the signed-in
|
|
132
|
+
* user; returns a single-use code bound to the app's PKCE challenge.
|
|
133
|
+
* - `POST /tauri/token`: called by the app's Rust plugin; exchanges the code
|
|
134
|
+
* and PKCE verifier for a new session created in the app's own request.
|
|
135
|
+
*
|
|
136
|
+
* Pair it with the `bearer()` plugin: the app authenticates with
|
|
137
|
+
* `Authorization: Bearer`, never with cookies.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* import { betterAuth } from 'better-auth';
|
|
142
|
+
* import { bearer } from 'better-auth/plugins';
|
|
143
|
+
* import { tauri } from 'tauri-plugin-better-auth';
|
|
144
|
+
*
|
|
145
|
+
* export const auth = betterAuth({
|
|
146
|
+
* plugins: [
|
|
147
|
+
* bearer(),
|
|
148
|
+
* tauri({ clientID: 'com.example.app', authorizeOrigins: ['https://example.com'] }),
|
|
149
|
+
* ],
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
declare const tauri: (options?: TauriOptions) => {
|
|
154
|
+
id: "tauri";
|
|
155
|
+
version: string;
|
|
156
|
+
init(ctx: InitContext): void;
|
|
157
|
+
endpoints: {
|
|
158
|
+
tauriAuthorize: import("better-call").StrictEndpoint<"/tauri/authorize", {
|
|
159
|
+
method: "POST";
|
|
160
|
+
body: import("zod").ZodObject<{
|
|
161
|
+
client_id: import("zod").ZodString;
|
|
162
|
+
state: import("zod").ZodString;
|
|
163
|
+
code_challenge: import("zod").ZodString;
|
|
164
|
+
code_challenge_method: import("zod").ZodOptional<import("zod").ZodString>;
|
|
165
|
+
}, import("zod/v4/core").$strip>;
|
|
166
|
+
use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
167
|
+
session: {
|
|
168
|
+
session: Record<string, any> & {
|
|
169
|
+
id: string;
|
|
170
|
+
createdAt: Date;
|
|
171
|
+
updatedAt: Date;
|
|
172
|
+
userId: string;
|
|
173
|
+
expiresAt: Date;
|
|
174
|
+
token: string;
|
|
175
|
+
ipAddress?: string | null | undefined;
|
|
176
|
+
userAgent?: string | null | undefined;
|
|
177
|
+
};
|
|
178
|
+
user: Record<string, any> & {
|
|
179
|
+
id: string;
|
|
180
|
+
createdAt: Date;
|
|
181
|
+
updatedAt: Date;
|
|
182
|
+
email: string;
|
|
183
|
+
emailVerified: boolean;
|
|
184
|
+
name: string;
|
|
185
|
+
image?: string | null | undefined;
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
}>>[];
|
|
189
|
+
allowedMediaTypes: string[];
|
|
190
|
+
metadata: {
|
|
191
|
+
noStore: boolean;
|
|
192
|
+
openapi: {
|
|
193
|
+
operationId: string;
|
|
194
|
+
description: string;
|
|
195
|
+
responses: {
|
|
196
|
+
200: {
|
|
197
|
+
description: string;
|
|
198
|
+
content: {
|
|
199
|
+
'application/json': {
|
|
200
|
+
schema: {
|
|
201
|
+
type: "object";
|
|
202
|
+
properties: {
|
|
203
|
+
code: {
|
|
204
|
+
type: string;
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
required: string[];
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
}, {
|
|
216
|
+
code: string;
|
|
217
|
+
}>;
|
|
218
|
+
tauriToken: import("better-call").StrictEndpoint<"/tauri/token", {
|
|
219
|
+
method: "POST";
|
|
220
|
+
body: import("zod").ZodObject<{
|
|
221
|
+
client_id: import("zod").ZodString;
|
|
222
|
+
code: import("zod").ZodString;
|
|
223
|
+
state: import("zod").ZodString;
|
|
224
|
+
code_verifier: import("zod").ZodString;
|
|
225
|
+
}, import("zod/v4/core").$strip>;
|
|
226
|
+
allowedMediaTypes: string[];
|
|
227
|
+
metadata: {
|
|
228
|
+
scope: "http";
|
|
229
|
+
noStore: boolean;
|
|
230
|
+
openapi: {
|
|
231
|
+
operationId: string;
|
|
232
|
+
description: string;
|
|
233
|
+
responses: {
|
|
234
|
+
200: {
|
|
235
|
+
description: string;
|
|
236
|
+
content: {
|
|
237
|
+
'application/json': {
|
|
238
|
+
schema: {
|
|
239
|
+
type: "object";
|
|
240
|
+
properties: {
|
|
241
|
+
token: {
|
|
242
|
+
type: string;
|
|
243
|
+
};
|
|
244
|
+
user: {
|
|
245
|
+
$ref: string;
|
|
246
|
+
};
|
|
247
|
+
protocol: {
|
|
248
|
+
type: string;
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
required: string[];
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
}, {
|
|
260
|
+
token: string;
|
|
261
|
+
user: import("better-auth").User & Record<string, unknown>;
|
|
262
|
+
protocol: 1;
|
|
263
|
+
}>;
|
|
264
|
+
};
|
|
265
|
+
rateLimit: {
|
|
266
|
+
window: number;
|
|
267
|
+
max: number;
|
|
268
|
+
pathMatcher: (path: string) => boolean;
|
|
269
|
+
}[] | undefined;
|
|
270
|
+
options: ResolvedTauriOptions;
|
|
271
|
+
$ERROR_CODES: {
|
|
272
|
+
INVALID_CLIENT_ID: import("better-auth").RawError<"INVALID_CLIENT_ID">;
|
|
273
|
+
INVALID_CODE_CHALLENGE: import("better-auth").RawError<"INVALID_CODE_CHALLENGE">;
|
|
274
|
+
INVALID_CODE_VERIFIER: import("better-auth").RawError<"INVALID_CODE_VERIFIER">;
|
|
275
|
+
INVALID_OR_EXPIRED_CODE: import("better-auth").RawError<"INVALID_OR_EXPIRED_CODE">;
|
|
276
|
+
INVALID_STATE: import("better-auth").RawError<"INVALID_STATE">;
|
|
277
|
+
ORIGIN_NOT_ALLOWED: import("better-auth").RawError<"ORIGIN_NOT_ALLOWED">;
|
|
278
|
+
PKCE_VERIFICATION_FAILED: import("better-auth").RawError<"PKCE_VERIFICATION_FAILED">;
|
|
279
|
+
SESSION_REJECTED: import("better-auth").RawError<"SESSION_REJECTED">;
|
|
280
|
+
STATE_MISMATCH: import("better-auth").RawError<"STATE_MISMATCH">;
|
|
281
|
+
USER_NOT_FOUND: import("better-auth").RawError<"USER_NOT_FOUND">;
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
declare module '@better-auth/core' {
|
|
285
|
+
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
|
286
|
+
tauri: {
|
|
287
|
+
creator: typeof tauri;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
export { PROTOCOL_VERSION as a, isValidScheme as c, TauriOptions as d, CallbackTarget as i, TAURI_ERROR_CODES as l, VERSION as n, TauriConfigError as o, TauriTokenResponse as r, isLoopbackRedirectUri as s, tauri as t, ResolvedTauriOptions as u };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as PROTOCOL_VERSION, c as isValidScheme, d as TauriOptions, l as TAURI_ERROR_CODES, n as VERSION, o as TauriConfigError, r as TauriTokenResponse, s as isLoopbackRedirectUri, t as tauri, u as ResolvedTauriOptions } from "./index-DYZxnm2f.js";
|
|
2
|
+
export { PROTOCOL_VERSION, type ResolvedTauriOptions, TAURI_ERROR_CODES, TauriConfigError, type TauriOptions, type TauriTokenResponse, VERSION, isLoopbackRedirectUri, isValidScheme, tauri };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { a as CODE_VERIFIER_PATTERN, c as PROTOCOL_VERSION, d as TauriConfigError, f as assertClientId, g as isValidScheme, h as isLoopbackRedirectUri, i as CODE_PATTERN, n as CODE_CHALLENGE_PATTERN, r as CODE_IDENTIFIER_PREFIX, u as STATE_PATTERN } from "./protocol-Fcr7NzIh.js";
|
|
2
|
+
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
3
|
+
import { APIError } from "@better-auth/core/error";
|
|
4
|
+
import { createAuthEndpoint, sensitiveSessionMiddleware } from "better-auth/api";
|
|
5
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
6
|
+
import { constantTimeEqual, generateRandomString } from "better-auth/crypto";
|
|
7
|
+
import { parseUserOutput } from "better-auth/db";
|
|
8
|
+
import { generateCodeChallenge } from "better-auth/oauth2";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
//#region src/error-codes.ts
|
|
11
|
+
/**
|
|
12
|
+
* Error codes returned by the `/tauri/*` endpoints. Stable within protocol v1:
|
|
13
|
+
* the Rust plugin and apps switch on `code`, never on `message`.
|
|
14
|
+
*/
|
|
15
|
+
const TAURI_ERROR_CODES = defineErrorCodes({
|
|
16
|
+
INVALID_CLIENT_ID: "Unknown client id",
|
|
17
|
+
ORIGIN_NOT_ALLOWED: "This origin may not authorize a desktop sign-in",
|
|
18
|
+
INVALID_STATE: "Invalid state parameter",
|
|
19
|
+
INVALID_CODE_CHALLENGE: "Invalid PKCE code challenge (S256 required)",
|
|
20
|
+
INVALID_CODE_VERIFIER: "Invalid PKCE code verifier",
|
|
21
|
+
INVALID_OR_EXPIRED_CODE: "Invalid or expired authorization code",
|
|
22
|
+
STATE_MISMATCH: "State does not match the authorization request",
|
|
23
|
+
PKCE_VERIFICATION_FAILED: "PKCE verification failed",
|
|
24
|
+
USER_NOT_FOUND: "The user for this authorization no longer exists",
|
|
25
|
+
SESSION_REJECTED: "The server rejected the new session"
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/routes.ts
|
|
29
|
+
const authorizeBody = z.object({
|
|
30
|
+
client_id: z.string().max(64),
|
|
31
|
+
state: z.string().max(256),
|
|
32
|
+
code_challenge: z.string().max(256),
|
|
33
|
+
code_challenge_method: z.string().max(16).optional()
|
|
34
|
+
});
|
|
35
|
+
const tokenBody = z.object({
|
|
36
|
+
client_id: z.string().max(64),
|
|
37
|
+
code: z.string().max(256),
|
|
38
|
+
state: z.string().max(256),
|
|
39
|
+
code_verifier: z.string().max(256)
|
|
40
|
+
});
|
|
41
|
+
/** What `/tauri/authorize` stores under `tauri:<code>`. */
|
|
42
|
+
const storedGrant = z.object({
|
|
43
|
+
userId: z.string().min(1),
|
|
44
|
+
clientId: z.string().min(1),
|
|
45
|
+
codeChallenge: z.string().regex(CODE_CHALLENGE_PATTERN),
|
|
46
|
+
state: z.string().regex(STATE_PATTERN)
|
|
47
|
+
});
|
|
48
|
+
function parseStoredGrant(value) {
|
|
49
|
+
try {
|
|
50
|
+
return storedGrant.safeParse(JSON.parse(value));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const createAuthorizeEndpoint = (opts) => createAuthEndpoint("/tauri/authorize", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: authorizeBody,
|
|
58
|
+
use: [sensitiveSessionMiddleware],
|
|
59
|
+
allowedMediaTypes: ["application/json"],
|
|
60
|
+
metadata: {
|
|
61
|
+
noStore: true,
|
|
62
|
+
openapi: {
|
|
63
|
+
operationId: "tauriAuthorize",
|
|
64
|
+
description: "Mint a single-use code that hands the signed-in user's sign-in to a Tauri app. Called by the hand-off page after the user consents.",
|
|
65
|
+
responses: { 200: {
|
|
66
|
+
description: "A single-use authorization code",
|
|
67
|
+
content: { "application/json": { schema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: { code: { type: "string" } },
|
|
70
|
+
required: ["code"]
|
|
71
|
+
} } }
|
|
72
|
+
} }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, async (ctx) => {
|
|
76
|
+
if (opts.authorizeOrigins && ctx.request) {
|
|
77
|
+
const origin = ctx.request.headers.get("origin");
|
|
78
|
+
if (!origin || !opts.authorizeOrigins.includes(origin)) throw APIError.from("FORBIDDEN", TAURI_ERROR_CODES.ORIGIN_NOT_ALLOWED);
|
|
79
|
+
}
|
|
80
|
+
const { client_id, state, code_challenge, code_challenge_method } = ctx.body;
|
|
81
|
+
if (client_id !== opts.clientID) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_CLIENT_ID);
|
|
82
|
+
if (!STATE_PATTERN.test(state)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_STATE);
|
|
83
|
+
if (!CODE_CHALLENGE_PATTERN.test(code_challenge) || code_challenge_method !== void 0 && code_challenge_method !== "S256") throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_CODE_CHALLENGE);
|
|
84
|
+
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
85
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
86
|
+
identifier: `${CODE_IDENTIFIER_PREFIX}${code}`,
|
|
87
|
+
value: JSON.stringify({
|
|
88
|
+
userId: ctx.context.session.user.id,
|
|
89
|
+
clientId: client_id,
|
|
90
|
+
codeChallenge: code_challenge,
|
|
91
|
+
state
|
|
92
|
+
}),
|
|
93
|
+
expiresAt: new Date(Date.now() + opts.codeExpiresIn * 1e3)
|
|
94
|
+
});
|
|
95
|
+
return ctx.json({ code });
|
|
96
|
+
});
|
|
97
|
+
const createTokenEndpoint = (opts) => createAuthEndpoint("/tauri/token", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: tokenBody,
|
|
100
|
+
allowedMediaTypes: ["application/json"],
|
|
101
|
+
metadata: {
|
|
102
|
+
scope: "http",
|
|
103
|
+
noStore: true,
|
|
104
|
+
openapi: {
|
|
105
|
+
operationId: "tauriToken",
|
|
106
|
+
description: "Exchange a single-use authorization code and PKCE verifier for a new session. Called by the Tauri app, never by a browser.",
|
|
107
|
+
responses: { 200: {
|
|
108
|
+
description: "The new session token and the user",
|
|
109
|
+
content: { "application/json": { schema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
token: { type: "string" },
|
|
113
|
+
user: { $ref: "#/components/schemas/User" },
|
|
114
|
+
protocol: { type: "number" }
|
|
115
|
+
},
|
|
116
|
+
required: [
|
|
117
|
+
"token",
|
|
118
|
+
"user",
|
|
119
|
+
"protocol"
|
|
120
|
+
]
|
|
121
|
+
} } }
|
|
122
|
+
} }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}, async (ctx) => {
|
|
126
|
+
const { client_id, code, state, code_verifier } = ctx.body;
|
|
127
|
+
if (client_id !== opts.clientID) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_CLIENT_ID);
|
|
128
|
+
if (!CODE_PATTERN.test(code)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_OR_EXPIRED_CODE);
|
|
129
|
+
if (!STATE_PATTERN.test(state)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_STATE);
|
|
130
|
+
if (!CODE_VERIFIER_PATTERN.test(code_verifier)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_CODE_VERIFIER);
|
|
131
|
+
const record = await ctx.context.internalAdapter.consumeVerificationValue(`${CODE_IDENTIFIER_PREFIX}${code}`);
|
|
132
|
+
if (!record) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_OR_EXPIRED_CODE);
|
|
133
|
+
const grant = parseStoredGrant(record.value);
|
|
134
|
+
if (!grant?.success) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_OR_EXPIRED_CODE);
|
|
135
|
+
if (!constantTimeEqual(grant.data.clientId, client_id)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.INVALID_CLIENT_ID);
|
|
136
|
+
if (!constantTimeEqual(grant.data.state, state)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.STATE_MISMATCH);
|
|
137
|
+
const challenge = await generateCodeChallenge(code_verifier);
|
|
138
|
+
if (!constantTimeEqual(challenge, grant.data.codeChallenge)) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.PKCE_VERIFICATION_FAILED);
|
|
139
|
+
const user = await ctx.context.internalAdapter.findUserById(grant.data.userId);
|
|
140
|
+
if (!user) throw APIError.from("BAD_REQUEST", TAURI_ERROR_CODES.USER_NOT_FOUND);
|
|
141
|
+
const session = await ctx.context.internalAdapter.createSession(user.id);
|
|
142
|
+
if (!session) throw APIError.from("FORBIDDEN", TAURI_ERROR_CODES.SESSION_REJECTED);
|
|
143
|
+
await setSessionCookie(ctx, {
|
|
144
|
+
session,
|
|
145
|
+
user
|
|
146
|
+
});
|
|
147
|
+
return ctx.json({
|
|
148
|
+
token: session.token,
|
|
149
|
+
user: parseUserOutput(ctx.context.options, user),
|
|
150
|
+
protocol: 1
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/version.ts
|
|
155
|
+
/**
|
|
156
|
+
* Package version. Released in lockstep with the Rust crate; CI fails when this
|
|
157
|
+
* constant, `package.json` and `Cargo.toml` disagree.
|
|
158
|
+
*/
|
|
159
|
+
const VERSION = "0.1.0";
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/index.ts
|
|
162
|
+
function resolveOptions(options = {}) {
|
|
163
|
+
const clientID = assertClientId(options.clientID ?? "tauri");
|
|
164
|
+
const codeExpiresIn = options.codeExpiresIn ?? 300;
|
|
165
|
+
if (!Number.isInteger(codeExpiresIn) || codeExpiresIn < 30 || codeExpiresIn > 3600) throw new TauriConfigError(`codeExpiresIn must be a whole number of seconds between 30 and 3600, got ${codeExpiresIn}.`);
|
|
166
|
+
return {
|
|
167
|
+
clientID,
|
|
168
|
+
codeExpiresIn,
|
|
169
|
+
authorizeOrigins: options.authorizeOrigins?.map((origin) => {
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = new URL(origin);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new TauriConfigError(`authorizeOrigins entry "${origin}" is not a valid URL origin.`);
|
|
175
|
+
}
|
|
176
|
+
if (parsed.origin === "null" || parsed.origin !== origin.replace(/\/$/, "")) throw new TauriConfigError(`authorizeOrigins entry "${origin}" must be a bare origin such as "https://example.com".`);
|
|
177
|
+
return parsed.origin;
|
|
178
|
+
}),
|
|
179
|
+
rateLimit: options.rateLimit ?? {
|
|
180
|
+
window: 60,
|
|
181
|
+
max: 10
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Better Auth server plugin for Tauri desktop apps.
|
|
187
|
+
*
|
|
188
|
+
* Adds two endpoints:
|
|
189
|
+
* - `POST /tauri/authorize`: called by your hand-off web page for the signed-in
|
|
190
|
+
* user; returns a single-use code bound to the app's PKCE challenge.
|
|
191
|
+
* - `POST /tauri/token`: called by the app's Rust plugin; exchanges the code
|
|
192
|
+
* and PKCE verifier for a new session created in the app's own request.
|
|
193
|
+
*
|
|
194
|
+
* Pair it with the `bearer()` plugin: the app authenticates with
|
|
195
|
+
* `Authorization: Bearer`, never with cookies.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* import { betterAuth } from 'better-auth';
|
|
200
|
+
* import { bearer } from 'better-auth/plugins';
|
|
201
|
+
* import { tauri } from 'tauri-plugin-better-auth';
|
|
202
|
+
*
|
|
203
|
+
* export const auth = betterAuth({
|
|
204
|
+
* plugins: [
|
|
205
|
+
* bearer(),
|
|
206
|
+
* tauri({ clientID: 'com.example.app', authorizeOrigins: ['https://example.com'] }),
|
|
207
|
+
* ],
|
|
208
|
+
* });
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
const tauri = (options) => {
|
|
212
|
+
const opts = resolveOptions(options);
|
|
213
|
+
const endpoints = {
|
|
214
|
+
tauriAuthorize: createAuthorizeEndpoint(opts),
|
|
215
|
+
tauriToken: createTokenEndpoint(opts)
|
|
216
|
+
};
|
|
217
|
+
const isTauriPath = (path) => path === "/tauri/authorize" || path === "/tauri/token";
|
|
218
|
+
return {
|
|
219
|
+
id: "tauri",
|
|
220
|
+
version: VERSION,
|
|
221
|
+
init(ctx) {
|
|
222
|
+
if (!ctx.hasPlugin("bearer")) ctx.logger.warn("[tauri-plugin-better-auth] The bearer() plugin is not registered. Tauri apps authenticate with \"Authorization: Bearer\"; add bearer() from \"better-auth/plugins\" to your plugins.");
|
|
223
|
+
},
|
|
224
|
+
endpoints,
|
|
225
|
+
rateLimit: opts.rateLimit === false ? void 0 : [{
|
|
226
|
+
window: opts.rateLimit.window,
|
|
227
|
+
max: opts.rateLimit.max,
|
|
228
|
+
pathMatcher: isTauriPath
|
|
229
|
+
}],
|
|
230
|
+
options: opts,
|
|
231
|
+
$ERROR_CODES: TAURI_ERROR_CODES
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
//#endregion
|
|
235
|
+
export { PROTOCOL_VERSION, TAURI_ERROR_CODES, TauriConfigError, VERSION, isLoopbackRedirectUri, isValidScheme, tauri };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/protocol.ts
|
|
2
|
+
/**
|
|
3
|
+
* Wire protocol v1, shared by the server plugin, the web hand-off client and
|
|
4
|
+
* the Rust plugin. Documented in PROTOCOL.md. Within v1, changes are additive
|
|
5
|
+
* only: installed desktop apps lag behind servers and must keep working.
|
|
6
|
+
*
|
|
7
|
+
* This module has no runtime dependencies so every entry point can import it.
|
|
8
|
+
*/
|
|
9
|
+
/** Protocol version sent as `v` on the sign-in URL and returned by `/tauri/token`. */
|
|
10
|
+
const PROTOCOL_VERSION = 1;
|
|
11
|
+
/** Default client id when none is configured. */
|
|
12
|
+
const DEFAULT_CLIENT_ID = "tauri";
|
|
13
|
+
/** Default path of the app callback (`<scheme>://auth/callback`). */
|
|
14
|
+
const DEFAULT_CALLBACK_PATH = "/auth/callback";
|
|
15
|
+
/** Prefix of the verification identifier that stores an authorization code. */
|
|
16
|
+
const CODE_IDENTIFIER_PREFIX = "tauri:";
|
|
17
|
+
/** OAuth `state`: 16–128 unreserved characters (RFC 3986). */
|
|
18
|
+
const STATE_PATTERN = /^[A-Za-z0-9._~-]{16,128}$/;
|
|
19
|
+
/** PKCE S256 challenge: base64url of a SHA-256 digest, no padding. */
|
|
20
|
+
const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
21
|
+
/** PKCE verifier: 43–128 unreserved characters (RFC 7636 §4.1). */
|
|
22
|
+
const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
23
|
+
/** Authorization code minted by `/tauri/authorize`. */
|
|
24
|
+
const CODE_PATTERN = /^[A-Za-z0-9]{32}$/;
|
|
25
|
+
/** Client id shared by the server, the hand-off page and the app. */
|
|
26
|
+
const CLIENT_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
27
|
+
/** Social provider id forwarded from the app to the hand-off page. */
|
|
28
|
+
const PROVIDER_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
29
|
+
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]{1,62}$/;
|
|
30
|
+
const CALLBACK_PATH_PATTERN = /^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
|
|
31
|
+
/**
|
|
32
|
+
* Schemes that must never be used for the app callback: they are either
|
|
33
|
+
* web schemes, browser-internal, or Tauri's own.
|
|
34
|
+
*/
|
|
35
|
+
const RESERVED_SCHEMES = /* @__PURE__ */ new Set([
|
|
36
|
+
"about",
|
|
37
|
+
"asset",
|
|
38
|
+
"blob",
|
|
39
|
+
"data",
|
|
40
|
+
"file",
|
|
41
|
+
"ftp",
|
|
42
|
+
"http",
|
|
43
|
+
"https",
|
|
44
|
+
"ipc",
|
|
45
|
+
"javascript",
|
|
46
|
+
"mailto",
|
|
47
|
+
"tauri",
|
|
48
|
+
"ws",
|
|
49
|
+
"wss"
|
|
50
|
+
]);
|
|
51
|
+
/** Thrown when a configuration value breaks a protocol rule. */
|
|
52
|
+
var TauriConfigError = class extends Error {
|
|
53
|
+
name = "TauriConfigError";
|
|
54
|
+
};
|
|
55
|
+
/** Returns true for a custom URI scheme the app can register. */
|
|
56
|
+
function isValidScheme(scheme) {
|
|
57
|
+
return SCHEME_PATTERN.test(scheme) && !RESERVED_SCHEMES.has(scheme);
|
|
58
|
+
}
|
|
59
|
+
/** Validates a scheme, throwing {@link TauriConfigError} with the reason. */
|
|
60
|
+
function assertScheme(scheme) {
|
|
61
|
+
if (!isValidScheme(scheme)) throw new TauriConfigError(`Invalid callback scheme "${scheme}". Use a lowercase custom scheme such as "com.example.app" (RFC 8252 §7.1); web and Tauri schemes are not allowed.`);
|
|
62
|
+
return scheme;
|
|
63
|
+
}
|
|
64
|
+
/** Normalizes and validates the callback path (`/auth/callback`). */
|
|
65
|
+
function normalizeCallbackPath(path) {
|
|
66
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
67
|
+
if (!CALLBACK_PATH_PATTERN.test(normalized) || normalized.length > 64) throw new TauriConfigError(`Invalid callback path "${path}". Use 1–64 characters of letters, digits, "_", "-" and "/" segments, like "/auth/callback".`);
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
/** Validates a client id, throwing {@link TauriConfigError} with the reason. */
|
|
71
|
+
function assertClientId(clientId) {
|
|
72
|
+
if (!CLIENT_ID_PATTERN.test(clientId)) throw new TauriConfigError(`Invalid client id "${clientId}". Use 1–64 characters of letters, digits, ".", "_" and "-".`);
|
|
73
|
+
return clientId;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns true when `uri` is a loopback redirect the hand-off page may follow:
|
|
77
|
+
* exactly `http://127.0.0.1:<port><callbackPath>` or
|
|
78
|
+
* `http://[::1]:<port><callbackPath>`, with no userinfo, query or fragment.
|
|
79
|
+
*
|
|
80
|
+
* Anything else is rejected so that a crafted sign-in link cannot send an
|
|
81
|
+
* authorization code to a remote server (RFC 8252 §7.3, §8.3).
|
|
82
|
+
*/
|
|
83
|
+
function isLoopbackRedirectUri(uri, callbackPath) {
|
|
84
|
+
if (!uri.startsWith("http://127.0.0.1:") && !uri.startsWith("http://[::1]:")) return false;
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(uri);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (url.protocol !== "http:") return false;
|
|
92
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
93
|
+
if (url.search !== "" || url.hash !== "") return false;
|
|
94
|
+
if (uri.includes("?") || uri.includes("#")) return false;
|
|
95
|
+
if (url.hostname !== "127.0.0.1" && url.hostname !== "[::1]") return false;
|
|
96
|
+
if (url.port === "") return false;
|
|
97
|
+
const port = Number(url.port);
|
|
98
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) return false;
|
|
99
|
+
return url.pathname === callbackPath;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Builds the URL the browser opens to hand the result back to the app:
|
|
103
|
+
* `<scheme>://auth/callback?code=…&state=…` or the loopback redirect URI.
|
|
104
|
+
*/
|
|
105
|
+
function buildCallbackUrl(target, params) {
|
|
106
|
+
const search = new URLSearchParams(params).toString();
|
|
107
|
+
if (target.kind === "loopback") return `${target.redirectUri}?${search}`;
|
|
108
|
+
return `${target.scheme}://${target.callbackPath.slice(1)}?${search}`;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { normalizeCallbackPath as _, CODE_VERIFIER_PATTERN as a, PROTOCOL_VERSION as c, TauriConfigError as d, assertClientId as f, isValidScheme as g, isLoopbackRedirectUri as h, CODE_PATTERN as i, PROVIDER_PATTERN as l, buildCallbackUrl as m, CODE_CHALLENGE_PATTERN as n, DEFAULT_CALLBACK_PATH as o, assertScheme as p, CODE_IDENTIFIER_PREFIX as r, DEFAULT_CLIENT_ID as s, CLIENT_ID_PATTERN as t, STATE_PATTERN as u };
|