Skip to content

Commit 3b61313

Browse files
authored
Merge pull request #217 from Merit-Systems/rfs/sessions
AppSessions for keeping track of multi session per user
2 parents a264f4f + 714d63e commit 3b61313

26 files changed

Lines changed: 268 additions & 94 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
-- AlterTable
2+
ALTER TABLE "refresh_tokens" ADD COLUMN "sessionId" UUID;
3+
4+
-- CreateTable
5+
CREATE TABLE "app_sessions" (
6+
"id" UUID NOT NULL,
7+
"userId" UUID NOT NULL,
8+
"echoAppId" UUID NOT NULL,
9+
"deviceName" TEXT,
10+
"userAgent" TEXT,
11+
"ipAddress" TEXT,
12+
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13+
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
14+
"lastSeenAt" TIMESTAMPTZ(6),
15+
"revokedAt" TIMESTAMPTZ(6),
16+
"isArchived" BOOLEAN NOT NULL DEFAULT false,
17+
18+
CONSTRAINT "app_sessions_pkey" PRIMARY KEY ("id")
19+
);
20+
21+
-- CreateIndex
22+
CREATE INDEX "app_sessions_userId_echoAppId_idx" ON "app_sessions"("userId", "echoAppId");
23+
24+
-- CreateIndex
25+
CREATE INDEX "app_sessions_echoAppId_isArchived_idx" ON "app_sessions"("echoAppId", "isArchived");
26+
27+
-- CreateIndex
28+
CREATE INDEX "refresh_tokens_userId_echoAppId_isArchived_idx" ON "refresh_tokens"("userId", "echoAppId", "isArchived");
29+
30+
-- CreateIndex
31+
CREATE INDEX "refresh_tokens_sessionId_isArchived_idx" ON "refresh_tokens"("sessionId", "isArchived");
32+
33+
-- CreateIndex
34+
CREATE INDEX "refresh_tokens_expiresAt_idx" ON "refresh_tokens"("expiresAt");
35+
36+
-- AddForeignKey
37+
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "app_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
38+
39+
-- AddForeignKey
40+
ALTER TABLE "app_sessions" ADD CONSTRAINT "app_sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
41+
42+
-- AddForeignKey
43+
ALTER TABLE "app_sessions" ADD CONSTRAINT "app_sessions_echoAppId_fkey" FOREIGN KEY ("echoAppId") REFERENCES "echo_apps"("id") ON DELETE CASCADE ON UPDATE CASCADE;

‎echo-control/prisma/schema.prisma‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ model User {
3131
transactions Transaction[]
3232
spendPoolUsage UserSpendPoolUsage[]
3333
ReferralCode ReferralCode[]
34+
appSessions AppSession[]
3435
3536
@@map("users")
3637
}
@@ -96,6 +97,7 @@ model EchoApp {
9697
currentReferralRewardId String? @db.Uuid // Reference to current active referral reward
9798
currentReferralReward ReferralReward? @relation("CurrentReferralReward", fields: [currentReferralRewardId], references: [id])
9899
ReferralRewards ReferralReward[] @relation("AppReferralRewards") // All referral rewards for this app
100+
appSessions AppSession[]
99101
100102
@@map("echo_apps")
101103
}
@@ -157,9 +159,37 @@ model RefreshToken {
157159
echoApp EchoApp @relation(fields: [echoAppId], references: [id], onDelete: Cascade)
158160
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
159161
162+
sessionId String? @db.Uuid
163+
session AppSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
164+
165+
@@index([userId, echoAppId, isArchived])
166+
@@index([sessionId, isArchived])
167+
@@index([expiresAt])
160168
@@map("refresh_tokens")
161169
}
162170

171+
model AppSession {
172+
id String @id @default(uuid()) @db.Uuid
173+
userId String @db.Uuid
174+
echoAppId String @db.Uuid
175+
deviceName String?
176+
userAgent String?
177+
ipAddress String?
178+
createdAt DateTime @default(now()) @db.Timestamptz(6)
179+
updatedAt DateTime @updatedAt @db.Timestamptz(6)
180+
lastSeenAt DateTime? @db.Timestamptz(6)
181+
revokedAt DateTime? @db.Timestamptz(6)
182+
isArchived Boolean @default(false)
183+
184+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
185+
echoApp EchoApp @relation(fields: [echoAppId], references: [id], onDelete: Cascade)
186+
refreshTokens RefreshToken[]
187+
188+
@@index([userId, echoAppId])
189+
@@index([echoAppId, isArchived])
190+
@@map("app_sessions")
191+
}
192+
163193
model Payment {
164194
id String @id @default(uuid()) @db.Uuid
165195
paymentId String @unique
@@ -296,7 +326,7 @@ model TransactionMetadata {
296326
inputTokens Int
297327
outputTokens Int
298328
totalTokens Int
299-
toolCost Decimal @default(0.0) @db.Decimal(65, 14)
329+
toolCost Decimal @default(0.0) @db.Decimal(65, 14)
300330
prompt String?
301331
isArchived Boolean @default(false)
302332
archivedAt DateTime? @db.Timestamptz(6)

‎echo-control/src/app/(auth)/api/oauth/token/route.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
2-
handleRefreshToken,
32
handleInitialTokenIssuance,
3+
handleRefreshToken,
44
} from '@/lib/jwt-tokens';
55
import { NextRequest, NextResponse } from 'next/server';
66

@@ -20,6 +20,19 @@ export async function POST(req: NextRequest) {
2020

2121
const { grant_type, code, redirect_uri, code_verifier } = body;
2222

23+
// Capture metadata from forwarded headers if present, fallback to standard
24+
const deviceName = undefined; // derive later from userAgent if desired
25+
const forwardedUserAgent =
26+
req.headers.get('x-client-user-agent') ||
27+
req.headers.get('user-agent') ||
28+
undefined;
29+
const forwardedIp =
30+
req.headers.get('x-client-ip') ||
31+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
32+
req.headers.get('x-real-ip') ||
33+
req.headers.get('cf-connecting-ip') ||
34+
undefined;
35+
2336
const client_id =
2437
body.client_id || req.nextUrl.searchParams.get('client_id');
2538

@@ -48,7 +61,11 @@ export async function POST(req: NextRequest) {
4861
);
4962
}
5063

51-
const result = await handleRefreshToken(refresh_token);
64+
const result = await handleRefreshToken(refresh_token, {
65+
userAgent: forwardedUserAgent,
66+
ipAddress: forwardedIp,
67+
deviceName,
68+
});
5269

5370
// Check if result is an error
5471
if ('error' in result) {
@@ -74,6 +91,9 @@ export async function POST(req: NextRequest) {
7491
redirect_uri,
7592
client_id,
7693
code_verifier,
94+
deviceName,
95+
userAgent: forwardedUserAgent,
96+
ipAddress: forwardedIp,
7797
});
7898

7999
// Check if result is an error

‎echo-control/src/lib/apps/soft-delete.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ export async function softDeleteEchoApp(appId: string) {
4949
},
5050
});
5151

52+
// Archive related app sessions
53+
await db.appSession.updateMany({
54+
where: {
55+
echoAppId: appId,
56+
isArchived: false,
57+
},
58+
data: {
59+
isArchived: true,
60+
revokedAt: now,
61+
updatedAt: now,
62+
},
63+
});
64+
5265
// Archive related app memberships
5366
await db.appMembership.updateMany({
5467
where: {
@@ -158,6 +171,18 @@ export async function softDeleteUser(userId: string) {
158171
},
159172
});
160173

174+
await db.appSession.updateMany({
175+
where: {
176+
userId,
177+
isArchived: false,
178+
},
179+
data: {
180+
isArchived: true,
181+
revokedAt: now,
182+
updatedAt: now,
183+
},
184+
});
185+
161186
// Finally archive the user
162187
return await db.user.update({
163188
where: { id: userId },

‎echo-control/src/lib/jwt-tokens.ts‎

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface ApiTokenPayload {
2727
app_id: string;
2828
scope: string;
2929
key_version: number;
30+
sid?: string;
3031
// Standard JWT claims
3132
sub: string; // user_id
3233
aud: string; // app_id
@@ -55,8 +56,9 @@ export async function createEchoAccessJwtToken(params: {
5556
scope: string;
5657
expiry: Date;
5758
keyVersion?: number;
59+
sessionId?: string;
5860
}): Promise<string> {
59-
const { userId, appId, scope, expiry, keyVersion = 1 } = params;
61+
const { userId, appId, scope, expiry, keyVersion = 1, sessionId } = params;
6062

6163
const tokenId = nanoid(16);
6264
const now = Math.floor(Date.now() / 1000); // divide by 1000 to convert to seconds
@@ -67,6 +69,7 @@ export async function createEchoAccessJwtToken(params: {
6769
app_id: appId,
6870
scope,
6971
key_version: keyVersion,
72+
...(sessionId ? { sid: sessionId } : {}),
7073
})
7174
.setProtectedHeader({ alg: 'HS256' })
7275
.setSubject(userId)
@@ -192,7 +195,8 @@ export interface RefreshTokenError {
192195
* Handle refresh token logic
193196
*/
194197
export async function handleRefreshToken(
195-
refreshToken: string
198+
refreshToken: string,
199+
metadata?: { deviceName?: string; userAgent?: string; ipAddress?: string }
196200
): Promise<RefreshTokenResponse | RefreshTokenError> {
197201
console.log('🔄 Processing refresh token request');
198202

@@ -204,6 +208,7 @@ export async function handleRefreshToken(
204208
include: {
205209
user: true,
206210
echoApp: true,
211+
session: true,
207212
},
208213
});
209214

@@ -240,15 +245,27 @@ export async function handleRefreshToken(
240245
};
241246
}
242247

243-
/* 3️⃣ Check if echo app is still active */
248+
/* 3️⃣ Check if session and echo app are still active */
249+
if (
250+
!echoRefreshTokenRecord.session ||
251+
echoRefreshTokenRecord.session.revokedAt
252+
) {
253+
return {
254+
error: 'invalid_grant',
255+
error_description: 'Session is revoked or invalid',
256+
};
257+
}
258+
259+
const sessionId = echoRefreshTokenRecord.session.id;
260+
244261
if (echoRefreshTokenRecord.echoApp.isArchived) {
245262
return {
246263
error: 'invalid_grant',
247264
error_description: 'Echo app is no longer active',
248265
};
249266
}
250267

251-
/* 4️⃣ Generate new refresh token */
268+
/* 4️⃣ Generate new refresh token (rotate within the same session) */
252269
const newEchoRefreshTokenValue = `refresh_${nanoid(48)}`;
253270
const newEchoRefreshTokenExpiry = createEchoRefreshTokenExpiry();
254271

@@ -266,6 +283,18 @@ export async function handleRefreshToken(
266283
userId: echoRefreshTokenRecord.userId,
267284
echoAppId: echoRefreshTokenRecord.echoAppId,
268285
scope: echoRefreshTokenRecord.scope,
286+
sessionId,
287+
},
288+
});
289+
290+
// Update session last seen
291+
await db.appSession.update({
292+
where: { id: sessionId },
293+
data: {
294+
lastSeenAt: new Date(),
295+
...(metadata?.userAgent ? { userAgent: metadata.userAgent } : {}),
296+
...(metadata?.ipAddress ? { ipAddress: metadata.ipAddress } : {}),
297+
...(metadata?.deviceName ? { deviceName: metadata.deviceName } : {}),
269298
},
270299
});
271300

@@ -278,6 +307,7 @@ export async function handleRefreshToken(
278307
scope: echoRefreshTokenRecord.scope,
279308
keyVersion: 1,
280309
expiry: newEchoAccessTokenExpiry,
310+
sessionId,
281311
});
282312

283313
/* 6️⃣ Return new tokens with user and app info */
@@ -313,6 +343,9 @@ export interface AuthCodeTokenRequest {
313343
redirect_uri: string;
314344
client_id: string;
315345
code_verifier: string;
346+
deviceName?: string;
347+
userAgent?: string;
348+
ipAddress?: string;
316349
}
317350

318351
/**
@@ -331,7 +364,15 @@ export type InitialTokenError = RefreshTokenError;
331364
export async function handleInitialTokenIssuance(
332365
params: AuthCodeTokenRequest
333366
): Promise<InitialTokenResponse | InitialTokenError> {
334-
const { code, redirect_uri, client_id, code_verifier } = params;
367+
const {
368+
code,
369+
redirect_uri,
370+
client_id,
371+
code_verifier,
372+
deviceName,
373+
userAgent,
374+
ipAddress,
375+
} = params;
335376

336377
console.log('🎫 Processing authorization code token request');
337378

@@ -473,28 +514,28 @@ export async function handleInitialTokenIssuance(
473514
};
474515
}
475516

476-
/* 🔟 Generate refresh token */
477-
const echoRefreshTokenValue = `refresh_${nanoid(48)}`;
478-
const echoRefreshTokenExpiry = createEchoRefreshTokenExpiry();
479-
480-
// Deactivate any existing refresh tokens for this app
481-
await db.refreshToken.updateMany({
482-
where: {
517+
/* 🔟 Create an app session and refresh token */
518+
const appSession = await db.appSession.create({
519+
data: {
483520
userId: user.id,
484521
echoAppId: echoApp.id,
485-
isArchived: false,
522+
deviceName,
523+
userAgent,
524+
ipAddress,
486525
},
487-
data: { isArchived: true },
488526
});
489527

490-
// Create new refresh token
528+
const echoRefreshTokenValue = `refresh_${nanoid(48)}`;
529+
const echoRefreshTokenExpiry = createEchoRefreshTokenExpiry();
530+
491531
const newEchoRefreshToken = await db.refreshToken.create({
492532
data: {
493533
token: echoRefreshTokenValue,
494534
expiresAt: echoRefreshTokenExpiry,
495535
userId: user.id,
496536
echoAppId: echoApp.id,
497537
scope: authData.scope,
538+
sessionId: appSession.id,
498539
},
499540
});
500541

@@ -507,6 +548,7 @@ export async function handleInitialTokenIssuance(
507548
scope: authData.scope,
508549
keyVersion: 1,
509550
expiry: newEchoAccessTokenExpiry,
551+
sessionId: appSession.id,
510552
});
511553

512554
console.log('🎫 JWT access token generated');

‎echo-next-sdk/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"author": "Merit Systems",
3838
"license": "MIT",
3939
"dependencies": {
40-
"@merit-systems/echo-typescript-sdk": "1.0.4",
40+
"@merit-systems/echo-typescript-sdk": "1.0.5",
4141
"ai": "^5.0.17",
4242
"jwt-decode": "^4.0.0"
4343
},

0 commit comments

Comments
 (0)