Skip to content

Passkey addon prototype #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 27 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions passkey-addon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# RedwoodSDK Passkey Addon

This addon provides passkey (WebAuthn) authentication for a RedwoodSDK project.

## How to add to your project

These instructions assume you are starting with a new RedwoodSDK project, for example from `npx create-rwsdk my-project-name`.

### 0. Download this addon

```
npx degit redwoodjs/sdk-experiments/passkey-addon#proto-passkey-addon .tmp_passkey_addon
```

### 1. Copy files

Copy the `src` directory from this addon into your project's root directory. This will add the following directories:

- `src/passkey`: Core logic for passkey authentication.
- `src/session`: Session management using a Durable Object.

### 2. Update `package.json`

Add the following dependencies to your `package.json` file:

```json
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^13.1.1",
"kysely": "^0.28.2",
"rwsdk": "0.1.0-alpha.17-test.20250619030106"
}
```

Then run `pnpm install`.

### 3. Update `wrangler.jsonc`

Update your `wrangler.jsonc` to add Durable Object bindings, environment variables, and database migrations.

```jsonc
{
// ... existing configuration ...

// Durable Objects configuration
"durable_objects": {
"bindings": [
{
"name": "SESSION_DURABLE_OBJECT",
"class_name": "SessionDurableObject"
},
{
"name": "PASSKEY_DURABLE_OBJECT",
"class_name": "PasskeyDurableObject"
}
]
},

// Environment variables
"vars": {
"WEBAUTHN_APP_NAME": "My Awesome App"
},

// Migrations
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["PasskeyDurableObject"]
}
]
}
```

Remember to change `WEBAUTHN_APP_NAME` to your app's name.

### 4. Update `src/worker.tsx`

Modify your `src/worker.tsx` to integrate the passkey authentication and routes.

```typescript
import { env } from "cloudflare:workers";
import { defineApp, ErrorResponse } from "rwsdk/worker";
import { index, render, route, prefix } from "rwsdk/router";
import { Document } from "@/app/Document";
import { Home } from "@/app/pages/Home";
import { setCommonHeaders } from "@/app/headers";
import { authRoutes } from "@/passkey/routes";
import { setupPasskeyAuth } from "@/passkey/setup";
import { Session } from "@/session/durableObject";

export { SessionDurableObject } from "@/session/durableObject";
export { PasskeyDurableObject } from "@/passkey/durableObject";

export type AppContext = {
session: Session | null;
};

export default defineApp([
setCommonHeaders(),
setupPasskeyAuth(),
render(Document, [
index([
({ ctx }) => {
if (!ctx.session?.userId) {
return new Response(null, {
status: 302,
headers: { Location: "/auth/login" },
});
}
},
Home,
]),
prefix("/auth", authRoutes()),
]),
]);
```

### 5. Update `src/app/pages/Home.tsx`

Add a login link to your `Home.tsx` page.

```typescript
import { RequestInfo } from "rwsdk/worker";

export function Home({ ctx }: RequestInfo) {
return (
<div>
<h1>Hello World</h1>
<p>
<a href="/auth/login">Login</a>
</p>
</div>
);
}
```

### 6. Run the dev server

Now you can run the dev server:

```shell
pnpm dev
```

You should now have a working passkey authentication flow in your RedwoodSDK application!
104 changes: 104 additions & 0 deletions passkey-addon/src/passkey/components/Login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

import { useState, useTransition } from "react";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
finishPasskeyLogin,
finishPasskeyRegistration,
startPasskeyLogin,
startPasskeyRegistration,
} from "../functions";

export function Login() {
const [username, setUsername] = useState("");
const [result, setResult] = useState("");
const [isPending, startTransition] = useTransition();

const passkeyLogin = async () => {
try {
// 1. Get a challenge from the worker
const options = await startPasskeyLogin();

// 2. Ask the browser to sign the challenge
const login = await startAuthentication({ optionsJSON: options });

// 3. Give the signed challenge to the worker to finish the login process
const success = await finishPasskeyLogin(login);

if (!success) {
setResult("Login failed");
} else {
setResult("Login successful!");
}
} catch (error: unknown) {
setResult(
`Login error: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
};

const passkeyRegister = async () => {
if (!username.trim()) {
setResult("Please enter a username");
return;
}

try {
// 1. Get a challenge from the worker
const options = await startPasskeyRegistration(username);
// 2. Ask the browser to sign the challenge
const registration = await startRegistration({ optionsJSON: options });

// 3. Give the signed challenge to the worker to finish the registration process
const success = await finishPasskeyRegistration(username, registration);

if (!success) {
setResult("Registration failed");
} else {
setResult("Registration successful!");
}
} catch (error: unknown) {
setResult(
`Registration error: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
};

const handlePerformPasskeyLogin = () => {
startTransition(() => void passkeyLogin());
};

const handlePerformPasskeyRegister = () => {
startTransition(() => void passkeyRegister());
};

const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newUsername = e.target.value;
setUsername(newUsername);
};

return (
<>
<input
type="text"
value={username}
onChange={handleUsernameChange}
placeholder="Username"
/>
<button onClick={handlePerformPasskeyLogin} disabled={isPending}>
{isPending ? <>...</> : "Login with passkey"}
</button>
<button onClick={handlePerformPasskeyRegister} disabled={isPending}>
{isPending ? <>...</> : "Register with passkey"}
</button>
{result && <div>{result}</div>}
</>
);
}
95 changes: 95 additions & 0 deletions passkey-addon/src/passkey/db/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createDb } from "rwsdk/db";
import debug from "rwsdk/debug";
import { env } from "cloudflare:workers";

const log = debug("passkey:db");

export interface User {
id: string;
username: string;
createdAt: string;
}

export interface Credential {
id: string;
userId: string;
createdAt: string;
credentialId: string;
publicKey: Uint8Array;
counter: number;
}

export interface Database {
users: User;
credentials: Credential;
}

export const db = createDb<Database>(
env.PASSKEY_DURABLE_OBJECT,
"passkey-main"
);

export async function createUser(username: string): Promise<User> {
const user: User = {
id: crypto.randomUUID(),
username,
createdAt: new Date().toISOString(),
};
await db.insertInto("users").values(user).execute();
return user;
}

export async function getUserById(id: string): Promise<User | undefined> {
return await db
.selectFrom("users")
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
}

export async function createCredential(
credential: Omit<Credential, "id" | "createdAt">
): Promise<Credential> {
log("Creating credential for user: %s", credential.userId);

const newCredential: Credential = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...credential,
};

await db.insertInto("credentials").values(newCredential).execute();
log("Credential created successfully: %s", newCredential.id);
return newCredential;
}

export async function getCredentialById(
credentialId: string
): Promise<Credential | undefined> {
return await db
.selectFrom("credentials")
.selectAll()
.where("credentialId", "=", credentialId)
.executeTakeFirst();
}

export async function updateCredentialCounter(
credentialId: string,
counter: number
): Promise<void> {
await db
.updateTable("credentials")
.set({ counter })
.where("credentialId", "=", credentialId)
.execute();
}

export async function getUserCredentials(
userId: string
): Promise<Credential[]> {
return await db
.selectFrom("credentials")
.selectAll()
.where("userId", "=", userId)
.execute();
}
1 change: 1 addition & 0 deletions passkey-addon/src/passkey/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./db";
Loading