diff --git a/changelog.md b/changelog.md index 6d6d5877..db4b4f8b 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,9 @@ Changelog ------------------- * Clients can now specify how long a one-time-token should be valid for. +* API clients can now request that one-time-tokens don't expire after use. +* The client_id is now validated to belong to the curent user when validating + one-time-tokens. 0.25.0 (2023-11-22) diff --git a/schemas/one-time-token-exchange.json b/schemas/one-time-token-exchange.json new file mode 100644 index 00000000..232afbfb --- /dev/null +++ b/schemas/one-time-token-exchange.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://curveballjs.org/schemas/a12nserver/one-time-token-exchange.json", + "type": "object", + "description": "Request body of the exchange one-time-token endpoint.", + "required": ["token","client_id"], + "additionalProperties": false, + + "properties": { + "token": { + "type": "string", + "description": "The token previously obtained with the 'generate one-time-token' endpoint." + }, + "client_id": { + "type": "string", + "description": "The OAuth2 client_id. This client will be associated with the generated token." + }, + "activateUser": { + "type": "boolean", + "description": "Activate the user if the token was valid." + }, + "dontExpire": { + "type": "boolean", + "description": "Don't expire the one-time-token even if it was correct." + } + } +} diff --git a/schemas/one-time-token.json b/schemas/one-time-token-generate.json similarity index 100% rename from schemas/one-time-token.json rename to schemas/one-time-token-generate.json diff --git a/src/one-time-token/controller/exchange.ts b/src/one-time-token/controller/exchange.ts index d150e50b..067b1136 100644 --- a/src/one-time-token/controller/exchange.ts +++ b/src/one-time-token/controller/exchange.ts @@ -1,7 +1,7 @@ import Controller from '@curveball/controller'; import { Context } from '@curveball/core'; -import { Forbidden, UnprocessableEntity } from '@curveball/http-errors'; +import { Forbidden } from '@curveball/http-errors'; import { tokenResponse } from '../../oauth2/formats/json'; @@ -14,24 +14,26 @@ type OtteRequest = { activateUser?: boolean; token: string; client_id: string; + dontExpire?: boolean; } class OneTimeTokenExchangeController extends Controller { - async post(ctx: Context) { + async post(ctx: Context) { ctx.privileges.require('a12n:one-time-token:exchange'); + ctx.request.validate('https://curveballjs.org/schemas/a12nserver/one-time-token-exchange.json'); + const principalService = new PrincipalService(ctx.privileges); - if (!ctx.request.body.token) { - throw new UnprocessableEntity('A token must be provided for the exchange'); - } - if (!ctx.request.body.client_id) { - throw new UnprocessableEntity('A client_id must be provided for the exchange'); + const client = await oauth2ClientService.findByClientId(ctx.request.body.client_id); + if (!ctx.privileges.isPrincipal(client.app)) { + throw new Forbidden(`The client_id ${ctx.request.body.client_id} is not associated with the currently authenticated app`); } - - const user = await tokenService.validateToken(ctx.request.body.token); - + const user = await tokenService.validateToken( + ctx.request.body.token, + ctx.request.body.dontExpire ?? false, + ); if (!user.active) { if (ctx.request.body.activateUser) { user.active = true; @@ -41,7 +43,6 @@ class OneTimeTokenExchangeController extends Controller { } } - const client = await oauth2ClientService.findByClientId(ctx.request.body.client_id); const oauth2Token = await oauth2Service.generateTokenOneTimeToken({ client, principal: user, diff --git a/src/one-time-token/service.ts b/src/one-time-token/service.ts index c8e8847f..80d6bcf2 100644 --- a/src/one-time-token/service.ts +++ b/src/one-time-token/service.ts @@ -34,17 +34,22 @@ export async function createToken(user: User, expiresIn: number | null): Promise * This function only works once for every token. * After calling this function, the token automatically gets deleted. */ -export async function validateToken(token: string): Promise { +export async function validateToken(token: string, dontExpire: boolean = false): Promise { - const query = 'SELECT token, user_id FROM reset_password_token WHERE token = ? AND expires_at > ?'; - const result = await db.raw(query, [token, Math.floor(Date.now() / 1000)]); + const result = await db('reset_password_token') + .select() + .where({token}) + .andWhere('expires_at', '>', Math.floor(Date.now() / 1000)) + .first(); - if (result[0].length !== 1) { - throw new BadRequest ('Failed to validate token'); + if (!result) { + throw new BadRequest('Failed to validate token'); } else { - await db.raw('DELETE FROM reset_password_token WHERE token = ?', [token]); + if (!dontExpire) { + await db('reset_password_token').delete().where({token}); + } const principalService = new PrincipalService('insecure'); - return principalService.findById(result[0][0].user_id) as Promise; + return principalService.findById(result.user_id, 'user'); } }