mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
clean up auth and platform types
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
-- Drop the platform identity from refresh_tokens. A refreshed access token now takes
|
||||||
|
-- `platform`/`platform_id` from the account, which is where the bound identity lives —
|
||||||
|
-- the copy stored at issue time was redundant, and went stale if the account's
|
||||||
|
-- identity changed mid-session. Kept in sync with REFRESH_SCHEMA_DDL in
|
||||||
|
-- src/refresh-db.ts.
|
||||||
|
|
||||||
|
ALTER TABLE refresh_tokens DROP COLUMN platform;
|
||||||
|
ALTER TABLE refresh_tokens DROP COLUMN platform_id;
|
||||||
+19
-30
@@ -30,6 +30,7 @@ import {
|
|||||||
json,
|
json,
|
||||||
OAuthError,
|
OAuthError,
|
||||||
PlatformIdsRequest,
|
PlatformIdsRequest,
|
||||||
|
PlatformType,
|
||||||
roleLookup,
|
roleLookup,
|
||||||
TokenRequest,
|
TokenRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
@@ -45,26 +46,9 @@ import type { App } from './context'
|
|||||||
const TOKEN_SCOPE =
|
const TOKEN_SCOPE =
|
||||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||||
|
|
||||||
/** Platform-type enum names by value, used for the token's `platform` claim. */
|
|
||||||
const PLATFORM_TYPES: Record<number, string> = {
|
|
||||||
[-1]: 'All',
|
|
||||||
0: 'Steam',
|
|
||||||
1: 'Oculus',
|
|
||||||
2: 'PlayStation',
|
|
||||||
3: 'Xbox',
|
|
||||||
4: 'RecNet',
|
|
||||||
5: 'IOS',
|
|
||||||
6: 'GooglePlay',
|
|
||||||
7: 'Standalone',
|
|
||||||
8: 'Pico',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PlatformType for Oculus, which `/cachedlogin/forplatformid` currently stubs out. */
|
|
||||||
const OCULUS_PLATFORM = 1
|
|
||||||
|
|
||||||
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
||||||
const FAKE_OCULUS_CACHED_LOGIN = {
|
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||||
platform: OCULUS_PLATFORM,
|
platform: PlatformType.Oculus,
|
||||||
platformId: '1',
|
platformId: '1',
|
||||||
accountId: 1,
|
accountId: 1,
|
||||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||||
@@ -302,7 +286,7 @@ const app = new Hono<App>()
|
|||||||
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
||||||
// genuine cached login there is no platform ticket behind this, so the client must
|
// genuine cached login there is no platform ticket behind this, so the client must
|
||||||
// prompt. Delete this branch once Oculus platform auth lands.
|
// prompt. Delete this branch once Oculus platform auth lands.
|
||||||
if (platformInt === OCULUS_PLATFORM) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||||
return c.json(
|
return c.json(
|
||||||
@@ -406,10 +390,12 @@ const app = new Hono<App>()
|
|||||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||||
// grant overrides them below with what was stored when the token was issued.
|
// grant overrides them below with what was stored when the token was issued.
|
||||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||||
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
|
|
||||||
const platformInt =
|
const platformInt =
|
||||||
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||||
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
// The token's `platform` claim is the PlatformType int. A grant that asserts no
|
||||||
|
// platform (a password login) falls back to Steam/0, the same default the account
|
||||||
|
// itself carries — see `accountPlatform`.
|
||||||
|
let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt
|
||||||
|
|
||||||
// The device this login came from. The client posts both on every grant; they're
|
// The device this login came from. The client posts both on every grant; they're
|
||||||
// unverified (client-picked) so they're recorded on the account, never trusted as
|
// unverified (client-picked) so they're recorded on the account, never trusted as
|
||||||
@@ -440,7 +426,7 @@ const app = new Hono<App>()
|
|||||||
let verifiedSteamId: string | null = null
|
let verifiedSteamId: string | null = null
|
||||||
const platformAsserted = !Number.isNaN(platformInt)
|
const platformAsserted = !Number.isNaN(platformInt)
|
||||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||||
if (platformInt !== 0) {
|
if (platformInt !== PlatformType.Steam) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -548,9 +534,10 @@ const app = new Hono<App>()
|
|||||||
400
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
accountId = String(refreshed.accountId)
|
// `platform`/`platform_id` aren't stored with the token — they're taken from
|
||||||
platform = refreshed.platform
|
// the account below, so a refreshed token always reflects the identity the
|
||||||
platformId = refreshed.platformId
|
// account is bound to now.
|
||||||
|
accountId = String(refreshed)
|
||||||
} else if (grantType === 'cached_login') {
|
} else if (grantType === 'cached_login') {
|
||||||
// Platform-authenticated login into an already-linked account. The client posts
|
// Platform-authenticated login into an already-linked account. The client posts
|
||||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||||
@@ -629,6 +616,12 @@ const app = new Hono<App>()
|
|||||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||||
// every login and every refresh_token grant.
|
// every login and every refresh_token grant.
|
||||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||||
|
// A refresh grant posts no platform of its own, so the identity comes off the
|
||||||
|
// account — the same read, and the only place the bound identity is authoritative.
|
||||||
|
if (grantType === 'refresh_token' && roleAccount) {
|
||||||
|
platform = accountPlatform(roleAccount)
|
||||||
|
platformId = roleAccount.platformId ?? ''
|
||||||
|
}
|
||||||
const accessToken = await generateToken(
|
const accessToken = await generateToken(
|
||||||
accountId,
|
accountId,
|
||||||
platformId,
|
platformId,
|
||||||
@@ -638,11 +631,7 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||||
const refreshToken = await issueRefreshToken(c.env.DB, {
|
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
|
||||||
accountId: Number(accountId),
|
|
||||||
platform,
|
|
||||||
platformId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
|
|||||||
@@ -49,19 +49,40 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlatformType, by value. The `platform` form field is posted as the integer; the
|
* PlatformType, the client's platform enum. Declaration order is wire order, and is
|
||||||
* token's `platform` claim carries the name. Only Steam (0) can actually be
|
* the single source for the schema and description below. The `platform` form field
|
||||||
* verified — see the platform-auth notes on `POST /connect/token`.
|
* is posted as the integer; the token's `platform` claim carries the name.
|
||||||
*/
|
*/
|
||||||
export const PlatformType = z
|
export const PlatformType = {
|
||||||
.union([z.literal(-1), z.int().min(0).max(8)])
|
All: -1,
|
||||||
|
Steam: 0,
|
||||||
|
Oculus: 1,
|
||||||
|
PlayStation: 2,
|
||||||
|
Xbox: 3,
|
||||||
|
RecNet: 4,
|
||||||
|
IOS: 5,
|
||||||
|
GooglePlay: 6,
|
||||||
|
Standalone: 7,
|
||||||
|
Pico: 8,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PlatformType by value. Only Steam can actually be verified — see the
|
||||||
|
* platform-auth notes on `POST /connect/token`.
|
||||||
|
*/
|
||||||
|
export const PlatformTypeSchema = z
|
||||||
|
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
||||||
.describe(
|
.describe(
|
||||||
'-1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico'
|
Object.entries(PlatformType)
|
||||||
|
.map(([name, value]) => `${value} ${name}`)
|
||||||
|
.join(', ')
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||||
export const CachedLogin = z.object({
|
export const CachedLogin = z.object({
|
||||||
platform: PlatformType,
|
platform: PlatformTypeSchema,
|
||||||
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
||||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||||
|
|||||||
+17
-30
@@ -1,21 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
|
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
|
||||||
* worker, migration 0003). Only a SHA-256 hash of each token is stored — never the
|
* worker, migration 0003). Only a SHA-256 hash of each token is stored — never the
|
||||||
* raw value — alongside the account + platform needed to re-mint an access token,
|
* raw value — alongside the account it logs in and an absolute expiry. Tokens are
|
||||||
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
|
* single-use: redeeming one deletes it, so a fresh token is issued each refresh
|
||||||
* fresh token is issued each refresh (rotation) and a replayed token stops working.
|
* (rotation) and a replayed token stops working.
|
||||||
|
*
|
||||||
|
* The platform identity is NOT kept here (dropped in 0006); a refreshed token takes
|
||||||
|
* it from the account, which is where the bound identity actually lives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Refresh tokens live this long (s) before the client must log in again. */
|
/** Refresh tokens live this long (s) before the client must log in again. */
|
||||||
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
|
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */
|
||||||
export const REFRESH_SCHEMA_DDL: string[] = [
|
export const REFRESH_SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token_hash TEXT PRIMARY KEY,
|
token_hash TEXT PRIMARY KEY,
|
||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
platform TEXT NOT NULL,
|
|
||||||
platform_id TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
)`,
|
)`,
|
||||||
@@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The login context needed to re-mint an access token from a refresh token. */
|
|
||||||
export interface RefreshContext {
|
|
||||||
accountId: number
|
|
||||||
platform: string
|
|
||||||
platformId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
|
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
|
||||||
async function hashToken(token: string): Promise<string> {
|
async function hashToken(token: string): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
|
||||||
@@ -37,47 +31,40 @@ async function hashToken(token: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mint and persist a new refresh token for the given login, returning the raw
|
* Mint and persist a new refresh token for the given account, returning the raw
|
||||||
* token — the only moment it exists in plaintext (only its hash is stored).
|
* token — the only moment it exists in plaintext (only its hash is stored).
|
||||||
*/
|
*/
|
||||||
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
|
export async function issueRefreshToken(db: D1Database, accountId: number): Promise<string> {
|
||||||
const token = `${crypto.randomUUID()}`
|
const token = `${crypto.randomUUID()}`
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
|
`INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
await hashToken(token),
|
|
||||||
ctx.accountId,
|
|
||||||
ctx.platform,
|
|
||||||
ctx.platformId,
|
|
||||||
now,
|
|
||||||
now + REFRESH_TTL_SECONDS
|
|
||||||
)
|
)
|
||||||
|
.bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS)
|
||||||
.run()
|
.run()
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
|
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
|
||||||
* rotation) and return its login context; otherwise return null. The delete is
|
* rotation) and return the account it logs in; otherwise return null. The delete is
|
||||||
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a
|
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a
|
||||||
* concurrent second attempt finds no row. An expired token is deleted and rejected.
|
* concurrent second attempt finds no row. An expired token is deleted and rejected.
|
||||||
*/
|
*/
|
||||||
export async function consumeRefreshToken(
|
export async function consumeRefreshToken(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
token: string
|
token: string
|
||||||
): Promise<RefreshContext | null> {
|
): Promise<number | null> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`DELETE FROM refresh_tokens WHERE token_hash = ?1
|
`DELETE FROM refresh_tokens WHERE token_hash = ?1
|
||||||
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
|
RETURNING account_id AS accountId, expires_at AS expiresAt`
|
||||||
)
|
)
|
||||||
.bind(await hashToken(token))
|
.bind(await hashToken(token))
|
||||||
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
|
.first<{ accountId: number; expiresAt: number }>()
|
||||||
if (!row || row.expiresAt < now) return null
|
if (!row || row.expiresAt < now) return null
|
||||||
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
|
return row.accountId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,9 +512,17 @@ describe('auth worker routes', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /connect/token maps the platform int to its enum name', async () => {
|
test('POST /connect/token carries the platform int on the token', async () => {
|
||||||
const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`)
|
const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`)
|
||||||
expect(payload.platform).toBe('Steam')
|
expect(payload.platform).toBe(5)
|
||||||
|
// `rn.plat` is the same int, not a pinned 0.
|
||||||
|
expect(payload['rn.plat']).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /connect/token defaults the platform claim when none is posted', async () => {
|
||||||
|
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(payload.platform).toBe(0)
|
||||||
|
expect(payload['rn.plat']).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
|
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
|
||||||
@@ -530,15 +538,44 @@ describe('auth worker routes', () => {
|
|||||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||||
)
|
)
|
||||||
expect(refreshed.status).toBe(200)
|
expect(refreshed.status).toBe(200)
|
||||||
// A fresh access token for the same account, carrying the stored platform.
|
|
||||||
const payload = decodePayload(refreshed.json.access_token as string)
|
const payload = decodePayload(refreshed.json.access_token as string)
|
||||||
expect(payload.sub).toBe('42')
|
expect(payload.sub).toBe('42')
|
||||||
expect(payload.platform).toBe('Steam')
|
// The platform identity comes off the account, not the refresh token. Account 42
|
||||||
expect(payload.platform_id).toBe('steam-123')
|
// has none bound (the posted `platform_id` above was never Steam-verified, so it
|
||||||
|
// was never written), so the refreshed token carries no identity either.
|
||||||
|
expect(payload.platform).toBe(0)
|
||||||
|
expect(payload.platform_id).toBe('')
|
||||||
// The refresh token is rotated (single-use), so a new one is returned.
|
// The refresh token is rotated (single-use), so a new one is returned.
|
||||||
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
|
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a refreshed token carries the identity bound to the account', async () => {
|
||||||
|
// A Steam-bound account: only a verified ticket writes `platformId`, so seed it
|
||||||
|
// directly rather than posting an (unverified) platform_id on the login.
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: 43,
|
||||||
|
username: 'Player43',
|
||||||
|
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||||
|
platform: 0,
|
||||||
|
platformId: 'steam-123',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
const refreshed = await postToken(
|
||||||
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
|
||||||
|
)
|
||||||
|
expect(refreshed.status).toBe(200)
|
||||||
|
const payload = decodePayload(refreshed.json.access_token as string)
|
||||||
|
expect(payload.sub).toBe('43')
|
||||||
|
expect(payload.platform).toBe(0)
|
||||||
|
expect(payload.platform_id).toBe('steam-123')
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
|
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
|
||||||
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||||
const refreshToken = login.json.refresh_token as string
|
const refreshToken = login.json.refresh_token as string
|
||||||
|
|||||||
Reference in New Issue
Block a user