feat: ip and user agent on sessin

This commit is contained in:
Bereket Engida
2024-08-28 11:06:56 +03:00
parent 358b4488c1
commit ed3579d19e
24 changed files with 7800 additions and 132 deletions

2
.gitignore vendored
View File

@@ -173,3 +173,5 @@ dist
# Finder (MacOS) folder config
.DS_Store
.notes/

Binary file not shown.

View File

@@ -85,7 +85,9 @@ export default function Page() {
Login with Github
</Button>
<Button variant="secondary" className="gap-2" onClick={async () => {
await authClient.passkey.signIn()
await authClient.passkey.signIn({
callbackURL: "/"
})
}}>
<Key size={16} />
Login with Passkey

View File

@@ -4,11 +4,18 @@ import { authClient } from "./lib/auth-client";
export async function middleware(request: NextRequest) {
const session = await authClient.session({
headers: request.headers,
options: {
headers: request.headers,
},
});
if (!session.data) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
const canInvite = await authClient.org.hasPermission({
permission: {
invitation: ["create"],
},
});
return NextResponse.next();
}

View File

@@ -49,6 +49,17 @@ export const getAuthTables = (options: BetterAuthOptions) => {
session: {
tableName: options.session?.modelName || "session",
fields: {
expiresAt: {
type: "date",
},
ipAddress: {
type: "string",
required: false,
},
userAgent: {
type: "string",
required: false,
},
userId: {
type: "string",
references: {
@@ -57,9 +68,6 @@ export const getAuthTables = (options: BetterAuthOptions) => {
onDelete: "cascade",
},
},
expiresAt: {
type: "date",
},
},
},
account: {

View File

@@ -38,11 +38,13 @@ export const createInternalAdapter = (
});
return createdUser;
},
createSession: async (userId: string) => {
const data = {
createSession: async (userId: string, request?: Request) => {
const data: Session = {
id: generateRandomString(32, alphabet("a-z", "0-9", "A-Z")),
userId,
expiresAt: getDate(sessionExpiration),
ipAddress: request?.headers.get("x-forwarded-for") || "",
userAgent: request?.headers.get("user-agent") || "",
};
const session = adapter.create<Session>({
model: tables.session.tableName,

View File

@@ -32,6 +32,8 @@ export const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.date(),
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
});
export type User = z.infer<typeof userSchema>;

View File

@@ -15,8 +15,25 @@ export const csrfMiddleware = createAuthMiddleware(
if (
ctx.request?.method !== "POST" ||
ctx.context.options.advanced?.disableCSRFCheck
)
) {
return;
}
const url = new URL(ctx.request.url);
console.log({
url: ctx.request.url,
});
/**
* If origin is the same as baseURL or if the
* origin is in the trustedOrigins then we
* don't need to check the CSRF token.
*/
if (
url.origin === ctx.context.baseURL ||
ctx.context.options.trustedOrigins?.includes(url.origin)
) {
return;
}
const csrfToken = ctx.body?.csrfToken;
const csrfCookie = await ctx.getSignedCookie(
ctx.context.authCookies.csrfToken.name,

View File

@@ -95,7 +95,10 @@ export const callbackOAuth = createAuthEndpoint(
if (!userId) throw new APIError("INTERNAL_SERVER_ERROR");
//create session
const session = await c.context.internalAdapter.createSession(userId);
const session = await c.context.internalAdapter.createSession(
userId,
c.request,
);
try {
await c.setSignedCookie(
c.context.authCookies.sessionToken.name,

View File

@@ -140,6 +140,7 @@ export const signInCredential = createAuthEndpoint(
const session = await ctx.context.internalAdapter.createSession(
user.user.id,
ctx.request,
);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,

View File

@@ -69,6 +69,7 @@ export const signUpCredential = createAuthEndpoint(
});
const session = await ctx.context.internalAdapter.createSession(
createdUser.id,
ctx.request,
);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,

View File

@@ -8,13 +8,15 @@ import { addCurrentURL, csrfPlugin, redirectPlugin } from "./fetch-plugins";
import { InferRoutes } from "./path-to-object";
import { ClientOptions, HasPlugin } from "./type";
import { getBaseURL } from "../utils/base-url";
import { Plugin } from "../types/plugins";
import type { router } from "../api";
export const createVanillaClient = <Auth extends BetterAuth = never>(
options?: ClientOptions,
) => {
type BAuth = Auth extends never ? BetterAuth : Auth;
type API = BAuth["api"];
type API = Auth extends never
? ReturnType<typeof router>["endpoints"]
: BAuth["api"];
const $fetch = createFetch({
...options,
baseURL: getBaseURL(options?.baseURL),
@@ -40,6 +42,7 @@ export const createVanillaClient = <Auth extends BetterAuth = never>(
$activeOrganization,
$listOrganizations,
},
$fetch,
};
type HasPasskeyConfig = HasPlugin<"passkey", BAuth>;
type HasOrganizationConfig = HasPlugin<"organization", BAuth>;
@@ -48,6 +51,7 @@ export const createVanillaClient = <Auth extends BetterAuth = never>(
| (HasPasskeyConfig extends true ? "passkey" : never)
| (HasOrganizationConfig extends true ? "setActiveOrganization" : never)
| "$atoms"
| "$fetch"
>;
const proxy = createDynamicPathProxy(actions, $fetch, {
"/create/organization": $listOrg,

View File

@@ -1,4 +1,4 @@
import { BetterFetchResponse } from "@better-fetch/fetch";
import { BetterFetchOption, BetterFetchResponse } from "@better-fetch/fetch";
import { Context, Endpoint } from "better-call";
import {
HasRequiredKeys,
@@ -24,10 +24,20 @@ type InferCtx<C extends Context<any, any>> = C["body"] extends Record<
string,
any
>
? C["body"]
? C["body"] & {
options?: BetterFetchOption<undefined, C["query"], C["params"]>;
}
: C["query"] extends Record<string, any>
? C["query"]
: never;
? {
query: C["query"];
options?: Omit<
BetterFetchOption<C["body"], C["query"], C["params"]>,
"query"
>;
}
: {
options?: BetterFetchOption<C["body"], C["query"], C["params"]>;
};
type MergeRoutes<T> = UnionToIntersection<T>;
type InferRoute<API> = API extends {
@@ -52,4 +62,12 @@ type InferRoute<API> = API extends {
>
: never
: never;
export type InferRoutes<API> = MergeRoutes<InferRoute<API>>;
export type InferRoutes<API extends Record<string, Endpoint>> = MergeRoutes<
InferRoute<API>
>;
export interface ProxyRequest {
options?: BetterFetchOption<any, any>;
query?: any;
[key: string]: any;
}

View File

@@ -1,5 +1,6 @@
import { BetterFetch, BetterFetchOption } from "@better-fetch/fetch";
import { PreinitializedWritableAtom } from "nanostores";
import { ProxyRequest } from "./path-to-object";
const knownPathMethods: Record<string, "POST" | "GET"> = {
"/sign-out": "POST",
@@ -9,12 +10,16 @@ const knownPathMethods: Record<string, "POST" | "GET"> = {
"/two-factor/send-otp": "POST",
};
function getMethod(path: string, args?: BetterFetchOption) {
function getMethod(path: string, args?: ProxyRequest) {
const method = knownPathMethods[path];
const { options, query, ...body } = args || {};
if (method) {
return method;
}
if (args?.body) {
if (options?.method) {
return options.method;
}
if (body && Object.keys(body).length > 0) {
return "POST";
}
return "GET";
@@ -49,13 +54,13 @@ export function createDynamicPathProxy<T extends Record<string, any>>(
)
.join("/");
const routePath = `/${path}`;
const method = getMethod(routePath, args[0]);
const body = args[0]?.body;
const query = args[0]?.query;
const arg = (args[0] || {}) as ProxyRequest;
const method = getMethod(routePath, arg);
const { query, options, ...body } = arg;
return await client(routePath, {
...args[0],
...options,
body: method === "GET" ? undefined : body,
query: method === "POST" ? undefined : query,
query: query,
method,
onSuccess() {
const signal = $signal?.[routePath as string];

View File

@@ -1,7 +1,7 @@
import { z, ZodArray, ZodLiteral, ZodObject, ZodOptional } from "zod";
import { User } from "../../adapters/schema";
import { createAuthEndpoint } from "../../api/call";
import { Plugin } from "../../types/plugins";
import { BetterAuthPlugin } from "../../types/plugins";
import { shimContext } from "../../utils/shim";
import {
createOrganization,
@@ -111,8 +111,8 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
hasPermission: createAuthEndpoint(
"/org/has-permission",
{
method: "GET",
query: z.object({
method: "POST",
body: z.object({
permission: z.record(z.string(), z.array(z.string())),
}) as unknown as ZodObject<{
permission: ZodObject<{
@@ -200,5 +200,5 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
},
},
},
} satisfies Plugin;
} satisfies BetterAuthPlugin;
};

View File

@@ -16,7 +16,7 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { Plugin } from "../../types/plugins";
import { BetterAuthPlugin } from "../../types/plugins";
import { APIError } from "better-call";
export interface PasskeyOptions {
@@ -249,45 +249,55 @@ export const passkey = (options: PasskeyOptions) => {
});
}
const verification = await verifyRegistrationResponse({
response: resp,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: options.rpID,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
try {
const verification = await verifyRegistrationResponse({
response: resp,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: options.rpID,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return ctx.json(null, {
status: 400,
});
}
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp,
} = registrationInfo;
const pubKey = Buffer.from(credentialPublicKey).toString("base64");
const userID = generateRandomString(32, alphabet("a-z", "0-9"));
const newPasskey: Passkey = {
userId: userData.id,
webauthnUserID: userID,
id: credentialID,
publicKey: pubKey,
counter,
deviceType: credentialDeviceType,
transports: resp.response.transports.join(","),
backedUp: credentialBackedUp,
createdAt: new Date(),
};
const newPasskeyRes = await ctx.context.adapter.create<Passkey>({
model: "passkey",
data: newPasskey,
});
return ctx.json(newPasskeyRes, {
status: 200,
});
} catch (e) {
console.log(e);
return ctx.json(null, {
status: 400,
body: {
message: "Registration failed",
},
});
}
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp,
} = registrationInfo;
const pubKey = Buffer.from(credentialPublicKey).toString("base64");
const userID = generateRandomString(32, alphabet("a-z", "0-9"));
const newPasskey: Passkey = {
userId: userData.id,
webauthnUserID: userID,
id: credentialID,
publicKey: pubKey,
counter,
deviceType: credentialDeviceType,
transports: resp.response.transports.join(","),
backedUp: credentialBackedUp,
createdAt: new Date(),
};
const newPasskeyRes = await ctx.context.adapter.create<Passkey>({
model: "passkey",
data: newPasskey,
});
return ctx.json(newPasskeyRes, {
status: 200,
});
},
),
verifyPasskeyAuthentication: createAuthEndpoint(
@@ -335,67 +345,79 @@ export const passkey = (options: PasskeyOptions) => {
},
});
}
const verification = await verifyAuthenticationResponse({
response: resp as AuthenticationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: opts.rpID,
authenticator: {
credentialID: passkey.id,
credentialPublicKey: new Uint8Array(
Buffer.from(passkey.publicKey, "base64"),
),
counter: passkey.counter,
transports: passkey.transports?.split(
",",
) as AuthenticatorTransportFuture[],
},
});
const { verified } = verification;
if (!verified)
return ctx.json(null, {
status: 401,
body: {
message: "verification failed",
try {
console.log({ resp });
const verification = await verifyAuthenticationResponse({
response: resp as AuthenticationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: opts.rpID,
authenticator: {
credentialID: passkey.id,
credentialPublicKey: new Uint8Array(
Buffer.from(passkey.publicKey, "base64"),
),
counter: passkey.counter,
transports: passkey.transports?.split(
",",
) as AuthenticatorTransportFuture[],
},
});
const { verified } = verification;
if (!verified)
return ctx.json(null, {
status: 401,
body: {
message: "verification failed",
},
});
await ctx.context.adapter.update<Passkey>({
model: "passkey",
where: [
{
field: "id",
value: passkey.id,
await ctx.context.adapter.update<Passkey>({
model: "passkey",
where: [
{
field: "id",
value: passkey.id,
},
],
update: {
counter: verification.authenticationInfo.newCounter,
},
});
const s = await ctx.context.internalAdapter.createSession(
passkey.userId,
ctx.request,
);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
s.id,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
if (callbackURL) {
return ctx.json({
url: callbackURL,
redirect: true,
session: s,
});
}
return ctx.json(
{
session: s,
},
{
status: 200,
},
);
} catch (e) {
ctx.context.logger.error(e);
return ctx.json(null, {
status: 400,
body: {
message: "Authentication failed",
},
],
update: {
counter: passkey.counter + 1,
},
});
const s = await ctx.context.internalAdapter.createSession(
passkey.userId,
);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
s.id,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
if (callbackURL) {
return ctx.json({
url: callbackURL,
redirect: true,
session: s,
});
}
return ctx.json(
{
session: s,
},
{
status: 200,
},
);
},
),
},
@@ -436,5 +458,5 @@ export const passkey = (options: PasskeyOptions) => {
},
},
},
} satisfies Plugin;
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,5 @@
import { BetterAuthPlugin } from "../../types/plugins";
export const rememberMePlugin = async () => {
return {} satisfies BetterAuthPlugin;
};

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { createAuthEndpoint } from "../../api/call";
import { Plugin } from "../../types/plugins";
import { BetterAuthPlugin } from "../../types/plugins";
import { totp2fa } from "./totp";
import { TwoFactorOptions, UserWithTwoFactor } from "./types";
import {
@@ -113,5 +113,5 @@ export const twoFactor = <O extends TwoFactorOptions>(options: O) => {
},
},
},
} satisfies Plugin;
} satisfies BetterAuthPlugin;
};

View File

@@ -94,8 +94,6 @@ export const otp2fa = (options?: OTPOptions) => {
Buffer.from(ctx.context.secret),
parseInt(randomNumber),
);
console.log(toCheckOtp, ctx.body.code);
if (toCheckOtp === ctx.body.code) {
ctx.setCookie(cookie.name, "", {
path: "/",

View File

@@ -160,4 +160,8 @@ export interface BetterAuthOptions {
*/
sendVerificationEmail?: (email: string, url: string) => Promise<void>;
};
/**
* List of trusted origins.
*/
trustedOrigins?: string[];
}

View File

@@ -13,7 +13,7 @@ export type PluginSchema = {
};
};
export type Plugin = {
export type BetterAuthPlugin = {
id: LiteralString;
endpoints: {
[key: string]: AuthEndpoint;

7579
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,4 @@
## TODO
[ ] handle migration when the config removes existing schema
[ ] handle migration when the config removes existing schema
[ ] refresh oauth tokens
[ ] remember me functionality