Add access token foundation
This commit is contained in:
parent
6bf096f6c2
commit
4e743df7f1
@ -11,6 +11,9 @@ async function generate(): Promise<void> {
|
||||
process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage');
|
||||
process.env.PUBLIC_BASE_URL ??= 'http://localhost:3007';
|
||||
process.env.DEVICE_BOOTSTRAP_SECRET ??= 'openapi-placeholder-secret';
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_SECRET ??=
|
||||
'openapi-placeholder-account-access-secret';
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ??= '900';
|
||||
process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000';
|
||||
|
||||
const { createApp } = await import('../src/app.factory');
|
||||
|
||||
243
backend/src/modules/auth/access-token.service.spec.ts
Normal file
243
backend/src/modules/auth/access-token.service.spec.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { randomUUID, createHmac } from 'node:crypto';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AccountSessionStatus } from '@prisma/client';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import {
|
||||
AccessTokenService,
|
||||
AccountAccessTokenClaims,
|
||||
} from './access-token.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
const signingSecret = 'test-account-access-signing-secret';
|
||||
const now = new Date('2026-06-28T12:00:00.000Z');
|
||||
const userId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const signPayload = (
|
||||
payload: Record<string, unknown>,
|
||||
secret = signingSecret,
|
||||
header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' },
|
||||
): string => {
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
|
||||
'base64url',
|
||||
);
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
||||
'base64url',
|
||||
);
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = createHmac('sha256', secret)
|
||||
.update(signingInput)
|
||||
.digest('base64url');
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
const buildService = () => {
|
||||
let currentAccessTokenVersion = 1;
|
||||
let sessionStatus: AccountSessionStatus = AccountSessionStatus.ACTIVE;
|
||||
const sessionService = {
|
||||
validateSession: jest.fn().mockImplementation(async (input) => {
|
||||
if (
|
||||
sessionStatus !== AccountSessionStatus.ACTIVE ||
|
||||
input.sessionId !== sessionId ||
|
||||
input.accessTokenVersion !== currentAccessTokenVersion
|
||||
) {
|
||||
throw new UnauthorizedException('Session is invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
userId,
|
||||
refreshTokenHash: 'stored-refresh-token-hash',
|
||||
status: sessionStatus,
|
||||
accessTokenVersion: currentAccessTokenVersion,
|
||||
expiresAt: new Date('2026-07-28T12:00:00.000Z'),
|
||||
lastRefreshedAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}),
|
||||
} as unknown as SessionService;
|
||||
const configService = {
|
||||
accountAccessTokenSecret: signingSecret,
|
||||
accountAccessTokenTtlSeconds: 900,
|
||||
} as AppConfigService;
|
||||
const service = new AccessTokenService(configService, sessionService);
|
||||
|
||||
return {
|
||||
service,
|
||||
sessionService,
|
||||
setCurrentAccessTokenVersion: (version: number) => {
|
||||
currentAccessTokenVersion = version;
|
||||
},
|
||||
setSessionStatus: (status: AccountSessionStatus) => {
|
||||
sessionStatus = status;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const issueToken = (service: AccessTokenService): string =>
|
||||
service.issueAccessToken(
|
||||
{
|
||||
userId,
|
||||
sessionId,
|
||||
accessTokenVersion: 1,
|
||||
},
|
||||
now,
|
||||
);
|
||||
|
||||
it('issues a signed token with the required account claims', () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const token = issueToken(service);
|
||||
const payload = service.decodeAccessToken(token);
|
||||
|
||||
expect(payload).toEqual({
|
||||
sub: userId,
|
||||
sid: sessionId,
|
||||
ver: 1,
|
||||
typ: 'account_access',
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(now.getTime() / 1000) + 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies a valid token and its backing session', async () => {
|
||||
const { service, sessionService } = buildService();
|
||||
const token = issueToken(service);
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).resolves.toMatchObject({
|
||||
sub: userId,
|
||||
sid: sessionId,
|
||||
ver: 1,
|
||||
typ: 'account_access',
|
||||
});
|
||||
expect(sessionService.validateSession).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId,
|
||||
accessTokenVersion: 1,
|
||||
},
|
||||
now,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an expired token before session validation', async () => {
|
||||
const { service, sessionService } = buildService();
|
||||
const token = issueToken(service);
|
||||
const expiration = new Date(now.getTime() + 900_000);
|
||||
|
||||
await expect(
|
||||
service.verifyAccessToken(token, expiration),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(sessionService.validateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong account token type', async () => {
|
||||
const { service } = buildService();
|
||||
const token = signPayload({
|
||||
sub: userId,
|
||||
sid: sessionId,
|
||||
ver: 1,
|
||||
typ: 'device_access',
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(now.getTime() / 1000) + 900,
|
||||
});
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a stale token version after session refresh', async () => {
|
||||
const { service, setCurrentAccessTokenVersion } = buildService();
|
||||
const token = issueToken(service);
|
||||
setCurrentAccessTokenVersion(2);
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token backed by a revoked session', async () => {
|
||||
const { service, setSessionStatus } = buildService();
|
||||
const token = issueToken(service);
|
||||
setSessionStatus(AccountSessionStatus.REVOKED);
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token backed by an expired session', async () => {
|
||||
const { service, setSessionStatus } = buildService();
|
||||
const token = issueToken(service);
|
||||
setSessionStatus(AccountSessionStatus.EXPIRED);
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token signed with a different secret', async () => {
|
||||
const { service, sessionService } = buildService();
|
||||
const payload: AccountAccessTokenClaims = {
|
||||
sub: userId,
|
||||
sid: sessionId,
|
||||
ver: 1,
|
||||
typ: 'account_access',
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(now.getTime() / 1000) + 900,
|
||||
};
|
||||
const token = signPayload({ ...payload }, 'different-signing-secret');
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(sessionService.validateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose email, password, or refresh token data', () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const payload = service.decodeAccessToken(issueToken(service));
|
||||
|
||||
expect(payload).not.toHaveProperty('email');
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
expect(payload).not.toHaveProperty('passwordHash');
|
||||
expect(payload).not.toHaveProperty('refreshToken');
|
||||
expect(payload).not.toHaveProperty('refreshTokenHash');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['sub', { sid: sessionId }],
|
||||
['sid', { sub: userId }],
|
||||
])('rejects a token missing %s', async (_claim, identityClaims) => {
|
||||
const { service } = buildService();
|
||||
const token = signPayload({
|
||||
...identityClaims,
|
||||
ver: 1,
|
||||
typ: 'account_access',
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(now.getTime() / 1000) + 900,
|
||||
});
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a valid session that belongs to a different subject', async () => {
|
||||
const { service, sessionService } = buildService();
|
||||
(sessionService.validateSession as jest.Mock).mockResolvedValue({
|
||||
id: sessionId,
|
||||
userId: randomUUID(),
|
||||
});
|
||||
const token = issueToken(service);
|
||||
|
||||
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
186
backend/src/modules/auth/access-token.service.ts
Normal file
186
backend/src/modules/auth/access-token.service.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
const ACCOUNT_ACCESS_TOKEN_TYPE = 'account_access' as const;
|
||||
const JWT_ALGORITHM = 'HS256' as const;
|
||||
|
||||
interface JwtHeader {
|
||||
alg: typeof JWT_ALGORITHM;
|
||||
typ: 'JWT';
|
||||
}
|
||||
|
||||
export interface IssueAccessTokenInput {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
accessTokenVersion: number;
|
||||
}
|
||||
|
||||
export interface AccountAccessTokenClaims {
|
||||
sub: string;
|
||||
sid: string;
|
||||
ver: number;
|
||||
typ: typeof ACCOUNT_ACCESS_TOKEN_TYPE;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export type DecodedAccessTokenPayload = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenService {
|
||||
constructor(
|
||||
private readonly configService: AppConfigService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
|
||||
issueAccessToken(input: IssueAccessTokenInput, now = new Date()): string {
|
||||
const issuedAt = Math.floor(now.getTime() / 1000);
|
||||
const claims: AccountAccessTokenClaims = {
|
||||
sub: input.userId,
|
||||
sid: input.sessionId,
|
||||
ver: input.accessTokenVersion,
|
||||
typ: ACCOUNT_ACCESS_TOKEN_TYPE,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + this.configService.accountAccessTokenTtlSeconds,
|
||||
};
|
||||
|
||||
const header: JwtHeader = {
|
||||
alg: JWT_ALGORITHM,
|
||||
typ: 'JWT',
|
||||
};
|
||||
const encodedHeader = this.encodeJson(header);
|
||||
const encodedPayload = this.encodeJson(claims);
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = this.sign(signingInput);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async verifyAccessToken(
|
||||
token: string,
|
||||
now = new Date(),
|
||||
): Promise<AccountAccessTokenClaims> {
|
||||
const [encodedHeader, encodedPayload, encodedSignature] =
|
||||
this.splitToken(token);
|
||||
const header = this.decodeJson(encodedHeader);
|
||||
|
||||
if (header.alg !== JWT_ALGORITHM || header.typ !== 'JWT') {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
|
||||
const expectedSignature = this.sign(
|
||||
`${encodedHeader}.${encodedPayload}`,
|
||||
);
|
||||
const suppliedSignature = Buffer.from(encodedSignature, 'utf8');
|
||||
const expectedSignatureBytes = Buffer.from(expectedSignature, 'utf8');
|
||||
|
||||
if (
|
||||
suppliedSignature.length !== expectedSignatureBytes.length ||
|
||||
!timingSafeEqual(suppliedSignature, expectedSignatureBytes)
|
||||
) {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
|
||||
const claims = this.assertClaims(this.decodeJson(encodedPayload), now);
|
||||
await this.assertAccessTokenSession(claims, now);
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
decodeAccessToken(token: string): DecodedAccessTokenPayload {
|
||||
const [, encodedPayload] = this.splitToken(token);
|
||||
return this.decodeJson(encodedPayload);
|
||||
}
|
||||
|
||||
async assertAccessTokenSession(
|
||||
claims: AccountAccessTokenClaims,
|
||||
now = new Date(),
|
||||
): Promise<void> {
|
||||
const session = await this.sessionService.validateSession(
|
||||
{
|
||||
sessionId: claims.sid,
|
||||
accessTokenVersion: claims.ver,
|
||||
},
|
||||
now,
|
||||
);
|
||||
|
||||
if (session.userId !== claims.sub) {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
}
|
||||
|
||||
private assertClaims(
|
||||
payload: DecodedAccessTokenPayload,
|
||||
now: Date,
|
||||
): AccountAccessTokenClaims {
|
||||
if (
|
||||
payload.typ !== ACCOUNT_ACCESS_TOKEN_TYPE ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
payload.sub.length === 0 ||
|
||||
typeof payload.sid !== 'string' ||
|
||||
payload.sid.length === 0 ||
|
||||
typeof payload.ver !== 'number' ||
|
||||
!Number.isInteger(payload.ver) ||
|
||||
payload.ver < 1 ||
|
||||
typeof payload.iat !== 'number' ||
|
||||
!Number.isInteger(payload.iat) ||
|
||||
typeof payload.exp !== 'number' ||
|
||||
!Number.isInteger(payload.exp) ||
|
||||
payload.exp <= Math.floor(now.getTime() / 1000)
|
||||
) {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
|
||||
return payload as unknown as AccountAccessTokenClaims;
|
||||
}
|
||||
|
||||
private splitToken(token: string): [string, string, string] {
|
||||
const segments = token.split('.');
|
||||
|
||||
if (
|
||||
segments.length !== 3 ||
|
||||
!segments[0] ||
|
||||
!segments[1] ||
|
||||
!segments[2]
|
||||
) {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
|
||||
return [segments[0], segments[1], segments[2]];
|
||||
}
|
||||
|
||||
private decodeJson(segment: string): DecodedAccessTokenPayload {
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(
|
||||
Buffer.from(segment, 'base64url').toString('utf8'),
|
||||
);
|
||||
|
||||
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
|
||||
return decoded as DecodedAccessTokenPayload;
|
||||
} catch {
|
||||
throw this.invalidToken();
|
||||
}
|
||||
}
|
||||
|
||||
private encodeJson(value: JwtHeader | AccountAccessTokenClaims): string {
|
||||
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
private sign(signingInput: string): string {
|
||||
return createHmac(
|
||||
'sha256',
|
||||
this.configService.accountAccessTokenSecret,
|
||||
)
|
||||
.update(signingInput)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
private invalidToken(): UnauthorizedException {
|
||||
return new UnauthorizedException('Invalid account access token');
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { DeviceAuthGuard } from './device-auth.guard';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
|
||||
@ -11,6 +12,7 @@ import { SessionService } from './session.service';
|
||||
@Module({
|
||||
imports: [PrismaModule, RequestContextModule],
|
||||
providers: [
|
||||
AccessTokenService,
|
||||
DeviceAuthService,
|
||||
DeviceAuthGuard,
|
||||
OptionalDeviceAuthGuard,
|
||||
@ -18,6 +20,7 @@ import { SessionService } from './session.service';
|
||||
SessionService,
|
||||
],
|
||||
exports: [
|
||||
AccessTokenService,
|
||||
DeviceAuthService,
|
||||
DeviceAuthGuard,
|
||||
OptionalDeviceAuthGuard,
|
||||
|
||||
@ -29,6 +29,14 @@ export class AppConfigService {
|
||||
return this.required('DEVICE_BOOTSTRAP_SECRET');
|
||||
}
|
||||
|
||||
get accountAccessTokenSecret(): string {
|
||||
return this.required('ACCOUNT_ACCESS_TOKEN_SECRET');
|
||||
}
|
||||
|
||||
get accountAccessTokenTtlSeconds(): number {
|
||||
return Number(this.required('ACCOUNT_ACCESS_TOKEN_TTL_SECONDS'));
|
||||
}
|
||||
|
||||
get maxUploadSizeBytes(): number {
|
||||
return Number(this.required('MAX_UPLOAD_SIZE_BYTES'));
|
||||
}
|
||||
|
||||
@ -8,11 +8,14 @@ describe('validateEnvironment', () => {
|
||||
DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public',
|
||||
STORAGE_ROOT: '/tmp/velody',
|
||||
PUBLIC_BASE_URL: 'http://localhost:3007',
|
||||
DEVICE_BOOTSTRAP_SECRET: 'secret',
|
||||
DEVICE_BOOTSTRAP_SECRET: 'device-secret',
|
||||
ACCOUNT_ACCESS_TOKEN_SECRET: 'account-secret',
|
||||
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
|
||||
MAX_UPLOAD_SIZE_BYTES: '1024',
|
||||
});
|
||||
|
||||
expect(result.PORT).toBe(3007);
|
||||
expect(result.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS).toBe(900);
|
||||
expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024);
|
||||
});
|
||||
|
||||
@ -23,4 +26,21 @@ describe('validateEnvironment', () => {
|
||||
}),
|
||||
).toThrow(/Invalid environment configuration/);
|
||||
});
|
||||
|
||||
it('rejects reuse of the device-token secret for account tokens', () => {
|
||||
expect(() =>
|
||||
validateEnvironment({
|
||||
NODE_ENV: 'test',
|
||||
PORT: '3007',
|
||||
DATABASE_URL:
|
||||
'postgresql://velody:velody@localhost:5432/velody?schema=public',
|
||||
STORAGE_ROOT: '/tmp/velody',
|
||||
PUBLIC_BASE_URL: 'http://localhost:3007',
|
||||
DEVICE_BOOTSTRAP_SECRET: 'shared-secret',
|
||||
ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret',
|
||||
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
|
||||
MAX_UPLOAD_SIZE_BYTES: '1024',
|
||||
}),
|
||||
).toThrow(/account and device token secrets must be different/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,6 +37,14 @@ class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
DEVICE_BOOTSTRAP_SECRET!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
ACCOUNT_ACCESS_TOKEN_SECRET!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
MAX_UPLOAD_SIZE_BYTES!: number;
|
||||
@ -59,6 +67,15 @@ export function validateEnvironment(config: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
validated.ACCOUNT_ACCESS_TOKEN_SECRET ===
|
||||
validated.DEVICE_BOOTSTRAP_SECRET
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid environment configuration: account and device token secrets must be different',
|
||||
);
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
|
||||
@ -11,5 +11,9 @@ process.env.PUBLIC_BASE_URL =
|
||||
process.env.PUBLIC_BASE_URL ?? 'http://localhost:3007';
|
||||
process.env.DEVICE_BOOTSTRAP_SECRET =
|
||||
process.env.DEVICE_BOOTSTRAP_SECRET ?? 'test-secret';
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_SECRET =
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_SECRET ?? 'test-account-access-secret';
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS =
|
||||
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ?? '900';
|
||||
process.env.MAX_UPLOAD_SIZE_BYTES =
|
||||
process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824';
|
||||
|
||||
2
infra/docker/env/backend.env.example
vendored
2
infra/docker/env/backend.env.example
vendored
@ -4,4 +4,6 @@ DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public
|
||||
STORAGE_ROOT=/app/runtime/storage
|
||||
PUBLIC_BASE_URL=http://localhost:3007
|
||||
DEVICE_BOOTSTRAP_SECRET=replace-me
|
||||
ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-a-distinct-secret
|
||||
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900
|
||||
MAX_UPLOAD_SIZE_BYTES=524288000
|
||||
|
||||
Loading…
Reference in New Issue
Block a user