Skip to content

Commit

Permalink
auth
Browse files Browse the repository at this point in the history
  • Loading branch information
jonmumm committed Dec 26, 2024
1 parent f285497 commit aa1231c
Show file tree
Hide file tree
Showing 9 changed files with 546 additions and 40 deletions.
78 changes: 75 additions & 3 deletions app/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { ActorServer } from "actor-kit";
import { createAccessToken } from "actor-kit/server";
import { z } from "zod";
import { REFRESH_TOKEN_COOKIE_KEY, SESSION_TOKEN_COOKIE_KEY } from "./constants";
import {
REFRESH_TOKEN_COOKIE_KEY,
SESSION_TOKEN_COOKIE_KEY,
} from "./constants";
import { sendVerificationCode } from "./email";
import { Env } from "./env";
import { UserMachine } from "./user.machine";
import {
createNewUserSession,
createOneTimeToken,
createRefreshToken,
createSessionToken,
generateVerificationCode,
Expand Down Expand Up @@ -40,6 +46,18 @@ export async function handleNewUser(request: Request, env: Env) {
secret: env.SESSION_JWT_SECRET,
});

const id = env.USER.idFromName(userId);
const USER = env.USER as DurableObjectNamespace<ActorServer<UserMachine>>;
await USER.get(id).spawn({
actorType: "user",
actorId: userId,
caller: {
type: "client",
id: userId,
},
input: {},
});

return new Response(
JSON.stringify({
userId,
Expand Down Expand Up @@ -186,6 +204,18 @@ export async function handleEmailVerify(request: Request, env: Env) {
// Link email to user
await linkEmailToUser(result.data.email, session.userId, env.KV_STORAGE);

// Here we send an event to the actor kit
// In future if we pull this file out into an auth package, we can have this be
// part of a set of hooks/callbacks when configuring via createAuthKitRouter
// so that we don't need to couple access to the UserMachine here
const id = env.USER.idFromName(session.userId);
const USER = env.USER as DurableObjectNamespace<ActorServer<UserMachine>>;

await USER.get(id).send({
type: "VERIFY_EMAIL",
email: result.data.email,
});

return new Response(JSON.stringify({ success: true }), { status: 200 });
}

Expand Down Expand Up @@ -237,7 +267,7 @@ export async function handleRefreshToken(request: Request, env: Env) {
export async function handleLogout(request: Request, env: Env) {
const headers = new Headers();
headers.set("Location", "/");

headers.append(
"Set-Cookie",
`${SESSION_TOKEN_COOKIE_KEY}=; HttpOnly; Secure; SameSite=Strict; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
Expand All @@ -249,7 +279,7 @@ export async function handleLogout(request: Request, env: Env) {

return new Response(null, {
status: 302,
headers
headers,
});
}

Expand Down Expand Up @@ -303,3 +333,45 @@ export async function handleActorToken(request: Request, env: Env) {

return new Response(JSON.stringify({ accessToken }), { status: 200 });
}

export async function handleOneTimeToken(request: Request, env: Env) {
// Get the user's ID from their session token
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401 }
);
}

const sessionToken = authHeader.slice(7);
const session = await verifySessionToken({
token: sessionToken,
secret: env.SESSION_JWT_SECRET,
});

if (!session) {
return new Response(JSON.stringify({ error: "Invalid session token" }), {
status: 401,
});
}

// Generate a one-time token
const oneTimeToken = await createOneTimeToken({
userId: session.userId,
secret: env.SESSION_JWT_SECRET,
});

// Return just the token
return new Response(
JSON.stringify({
token: oneTimeToken,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
);
}
28 changes: 26 additions & 2 deletions app/user.machine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ActorKitStateMachine } from "actor-kit";
import { setup } from "xstate";
import { produce } from "immer";
import { assign, setup } from "xstate";
import type { UserEvent, UserInput, UserServerContext } from "./user.types";

export const userMachine = setup({
Expand All @@ -8,7 +9,16 @@ export const userMachine = setup({
events: {} as UserEvent,
input: {} as UserInput,
},
actions: {},
actions: {
setEmail: assign({
private: ({ context, event }) =>
produce(context.private, (draft) => {
if (event.type === "VERIFY_EMAIL") {
draft[event.caller.id].email = event.email;
}
}),
}),
},
guards: {},
}).createMachine({
id: "user",
Expand All @@ -27,6 +37,20 @@ export const userMachine = setup({
Initialization: {
initial: "Ready",
states: {
EmailVerification: {
initial: "Incomplete",
states: {
Incomplete: {
on: {
VERIFY_EMAIL: {
target: "Complete",
actions: ["setEmail"],
},
},
},
Complete: {},
},
},
Ready: {},
},
},
Expand Down
3 changes: 2 additions & 1 deletion app/user.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const UserClientEventSchema = z.discriminatedUnion("type", [

export const UserServiceEventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("SYNC"),
type: z.literal("VERIFY_EMAIL"),
email: z.string(),
}),
]);

Expand Down
6 changes: 2 additions & 4 deletions app/user.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import type {
} from "actor-kit";
import { z } from "zod";
import { Env } from "./env";
import {
UserInputPropsSchema,
UserServiceEventSchema,
} from "./user.schemas";
import { UserInputPropsSchema, UserServiceEventSchema } from "./user.schemas";

export type UserClientEvent =
| { type: "START_GAME"; gameId: string }
Expand All @@ -26,6 +23,7 @@ type UserPublicContext = {

type UserPrivateContext = {
keypair?: string;
email?: string;
};

export type UserServerContext = {
Expand Down
34 changes: 34 additions & 0 deletions app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export async function createSessionToken({
.sign(new TextEncoder().encode(secret));
}

export async function createOneTimeToken({
userId,
secret,
}: {
userId: string;
secret: string;
}) {
return await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30s")
.setAudience("ONE_TIME")
.sign(new TextEncoder().encode(secret));
}

export async function createRefreshToken({
userId,
secret,
Expand Down Expand Up @@ -148,3 +162,23 @@ export async function getEmailByUserId(userId: string, kv: KVNamespace) {

return null;
}

export async function verifyOneTimeToken({
token,
secret,
}: {
token: string;
secret: string;
}) {
try {
const verified = await jwtVerify(token, new TextEncoder().encode(secret));
invariant(verified.payload.userId, "Missing userId in token payload");
invariant(
verified.payload.aud === "ONE_TIME",
"Invalid audience for one-time token"
);
return verified.payload as { userId: string };
} catch {
return null;
}
}
56 changes: 56 additions & 0 deletions docs/AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,62 @@ Generates actor-specific access tokens for Actor Kit.
}
```

### `/auth/one-time-token`
Generates a one-time token for cross-site authentication.
- Method: POST
- Requires valid session token
- Returns a short-lived JWT token containing the user ID
- Token expires in 30 seconds
- Example:
```http
POST /auth/one-time-token
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
Response:
```json
{
"token": "eyJhbGciOiJIUzI1NiIs..."
}
```

## Cross-Site Authentication Flow

1. Site A requests a one-time token:
```typescript
const response = await fetch('https://site-a.com/auth/one-time-token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentSessionToken}`
}
});
const { token } = await response.json();
```

2. Site A sends the token to Site B (via URL parameter, postMessage, etc.)

3. Site B verifies the token:
```typescript
import { jwtVerify } from "jose";

async function verifyOneTimeToken(token: string) {
try {
const verified = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET)
);

// Check token audience and expiry
if (verified.payload.aud !== "ONE_TIME") {
throw new Error("Invalid token type");
}

return verified.payload.userId as string;
} catch {
throw new Error("Invalid or expired token");
}
}
```

## Complete Example Flow

Here's a complete example of a user journey from anonymous to verified:
Expand Down
Loading

0 comments on commit aa1231c

Please sign in to comment.