Skip to content

Commit fec1df8

Browse files
authored
Merge pull request #355 from Stephan-Thomas/feat/jwt-refresh
feat(auth): add JWT refresh token flow — refresh endpoint, storage, r…
2 parents 08e296f + f3b3b92 commit fec1df8

5 files changed

Lines changed: 186 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- refresh_tokens: persistent storage for hashed refresh tokens
2+
-- Run this in Supabase SQL Editor
3+
4+
CREATE TABLE IF NOT EXISTS refresh_tokens (
5+
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
6+
user_address TEXT NOT NULL,
7+
token_hash TEXT NOT NULL UNIQUE,
8+
revoked BOOLEAN NOT NULL DEFAULT FALSE,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11+
last_used_at TIMESTAMPTZ,
12+
expires_at TIMESTAMPTZ
13+
);
14+
15+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_address ON refresh_tokens(user_address);
16+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_revoked ON refresh_tokens(revoked);
17+
18+
ALTER TABLE refresh_tokens ENABLE ROW LEVEL SECURITY;
19+
-- Access to this table should only be via the backend service_role key.
20+
21+
COMMENT ON TABLE refresh_tokens IS 'Hashed refresh tokens for rotating refresh-token flows';
22+
COMMENT ON COLUMN refresh_tokens.user_address IS 'Stellar wallet address of token owner';
23+
COMMENT ON COLUMN refresh_tokens.token_hash IS 'HMAC-SHA256 hash of the refresh token';
24+
COMMENT ON COLUMN refresh_tokens.revoked IS 'Flag indicating token revocation';
25+
COMMENT ON COLUMN refresh_tokens.created_at IS 'Creation timestamp';
26+
COMMENT ON COLUMN refresh_tokens.updated_at IS 'Last update timestamp';
27+
COMMENT ON COLUMN refresh_tokens.last_used_at IS 'Last time the token was used to refresh';
28+
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Expiration timestamp for the refresh token';

backend/src/auth/auth.controller.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { createZodPipe } from "../api/rest/raffles/pipes/zod-validation.pipe";
1616
import {
1717
GetNonceQuerySchema,
1818
VerifyBodySchema,
19+
RefreshBodySchema,
1920
VerifyBodyDto,
21+
RefreshBodyDto,
2022
} from "./auth.schema";
2123

2224
@ApiTags("Authentication")
@@ -67,4 +69,21 @@ export class AuthController {
6769
);
6870
}
6971
}
72+
73+
/**
74+
* POST /auth/refresh — Exchange a refresh token for new tokens.
75+
*/
76+
@Throttle({ auth: { limit: 30, ttl: 60000 } })
77+
@Post("refresh")
78+
@ApiOperation({ summary: "Exchange refresh token for new access + refresh tokens" })
79+
@UsePipes(new (createZodPipe(RefreshBodySchema))())
80+
async refresh(@Body() body: RefreshBodyDto) {
81+
try {
82+
return await this.authService.refresh(body.refreshToken);
83+
} catch (err) {
84+
throw new BadRequestException(
85+
err instanceof Error ? err.message : "Refresh failed",
86+
);
87+
}
88+
}
7089
}

backend/src/auth/auth.schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export const VerifyBodySchema = z.object({
2020
issuedAt: z.string().optional(),
2121
});
2222

23+
export const RefreshBodySchema = z.object({
24+
refreshToken: z
25+
.string({ required_error: "refreshToken is required" })
26+
.min(1, "refreshToken cannot be empty"),
27+
});
28+
2329
export class VerifyBodyDto {
2430
@ApiProperty({ description: "Stellar address of the user" })
2531
address: string;
@@ -33,3 +39,8 @@ export class VerifyBodyDto {
3339
@ApiPropertyOptional({ description: "Timestamp the signature was issued" })
3440
issuedAt?: string;
3541
}
42+
43+
export class RefreshBodyDto {
44+
@ApiProperty({ description: "Refresh token previously issued by the server" })
45+
refreshToken: string;
46+
}

backend/src/auth/auth.service.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, Inject } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
3-
import { randomBytes } from 'crypto';
3+
import { randomBytes, createHmac } from 'crypto';
44
import { SiwsService } from './siws.service';
5+
import { SUPABASE_CLIENT } from '../services/supabase.provider';
6+
import { SupabaseClient } from '@supabase/supabase-js';
7+
import { env } from '../config/env.config';
58

69
/** In-memory nonce store (use Redis in production for multi-instance). */
710
const nonces = new Map<
@@ -16,6 +19,7 @@ export class AuthService {
1619
constructor(
1720
private readonly jwtService: JwtService,
1821
private readonly siwsService: SiwsService,
22+
@Inject(SUPABASE_CLIENT) private readonly client: SupabaseClient,
1923
) {}
2024

2125
/**
@@ -72,8 +76,127 @@ export class AuthService {
7276
throw new Error('Invalid signature');
7377
}
7478

79+
// issue both access + refresh tokens and persist refresh token hash
80+
return await this.issueTokens(address);
81+
}
82+
83+
private signAccessToken(address: string) {
7584
const payload = { address };
76-
const accessToken = this.jwtService.sign(payload);
77-
return { accessToken };
85+
return this.jwtService.sign(payload, { expiresIn: env.jwt.expiresIn });
86+
}
87+
88+
private signRefreshToken(address: string) {
89+
const payload = { address, type: 'refresh' };
90+
return this.jwtService.sign(payload, {
91+
expiresIn: env.jwt.refreshExpiresIn,
92+
});
93+
}
94+
95+
private hashToken(token: string) {
96+
return createHmac('sha256', env.jwt.secret).update(token).digest('hex');
97+
}
98+
99+
private async storeRefreshHash(
100+
userAddress: string,
101+
tokenHash: string,
102+
expiresAtIso: string,
103+
) {
104+
// Upsert a refresh token row: allow multiple active tokens per user if desired.
105+
const { error } = await this.client.from('refresh_tokens').insert(
106+
[
107+
{
108+
user_address: userAddress,
109+
token_hash: tokenHash,
110+
created_at: new Date().toISOString(),
111+
last_used_at: new Date().toISOString(),
112+
expires_at: expiresAtIso,
113+
revoked: false,
114+
},
115+
],
116+
{ upsert: false },
117+
);
118+
119+
if (error) {
120+
throw new Error('Failed to persist refresh token');
121+
}
122+
}
123+
124+
async issueTokens(address: string): Promise<{ accessToken: string; refreshToken: string }> {
125+
const accessToken = this.signAccessToken(address);
126+
const refreshToken = this.signRefreshToken(address);
127+
128+
// compute expiry for refresh token record
129+
const now = new Date();
130+
// Parse refreshExpiresIn like '30d' or '7d' or seconds; for simplicity support days only
131+
const match = String(env.jwt.refreshExpiresIn).match(/(\d+)d$/);
132+
let expiresAt = new Date(now.getTime());
133+
if (match) {
134+
expiresAt.setDate(expiresAt.getDate() + parseInt(match[1], 10));
135+
} else {
136+
// fallback: 30 days
137+
expiresAt.setDate(expiresAt.getDate() + 30);
138+
}
139+
140+
const tokenHash = this.hashToken(refreshToken);
141+
await this.storeRefreshHash(address, tokenHash, expiresAt.toISOString());
142+
143+
return { accessToken, refreshToken };
144+
}
145+
146+
/**
147+
* Refresh flow: validate provided refresh token, rotate and issue new tokens.
148+
*/
149+
async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
150+
if (!refreshToken) throw new Error('refresh token required');
151+
152+
// verify token signature and expiry
153+
let payload: any;
154+
try {
155+
payload = this.jwtService.verify(refreshToken);
156+
} catch (err) {
157+
throw new Error('Invalid refresh token');
158+
}
159+
160+
if (payload.type !== 'refresh' || !payload.address) {
161+
throw new Error('Invalid refresh token payload');
162+
}
163+
164+
const tokenHash = this.hashToken(refreshToken);
165+
166+
// lookup token hash in DB
167+
const { data, error } = await this.client
168+
.from('refresh_tokens')
169+
.select('*')
170+
.eq('token_hash', tokenHash)
171+
.eq('revoked', false)
172+
.limit(1)
173+
.maybeSingle();
174+
175+
if (error || !data) {
176+
throw new Error('Refresh token not found or revoked');
177+
}
178+
179+
// optional: check expires_at
180+
if (data.expires_at && new Date(data.expires_at) < new Date()) {
181+
throw new Error('Refresh token expired');
182+
}
183+
184+
// rotate: create new refresh token and replace stored hash
185+
const address = payload.address as string;
186+
const newAccess = this.signAccessToken(address);
187+
const newRefresh = this.signRefreshToken(address);
188+
const newHash = this.hashToken(newRefresh);
189+
190+
const nowIso = new Date().toISOString();
191+
const { error: updateErr } = await this.client
192+
.from('refresh_tokens')
193+
.update({ token_hash: newHash, last_used_at: nowIso, updated_at: nowIso })
194+
.eq('token_hash', tokenHash);
195+
196+
if (updateErr) {
197+
throw new Error('Failed to rotate refresh token');
198+
}
199+
200+
return { accessToken: newAccess, refreshToken: newRefresh };
78201
}
79202
}

backend/src/config/env.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const env = {
2020
return {
2121
secret: process.env.JWT_SECRET ?? "dev-secret-change-in-production",
2222
expiresIn: process.env.JWT_EXPIRES_IN ?? "7d",
23+
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? "30d",
2324
};
2425
},
2526
get siws() {

0 commit comments

Comments
 (0)