diff --git a/changelog.md b/changelog.md index 870d7ba4..c0469d81 100644 --- a/changelog.md +++ b/changelog.md @@ -50,6 +50,7 @@ Changelog * 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. +* Fixed result of one-time-token if a custom expiry was used. 0.25.0 (2023-11-22) diff --git a/schemas/authorization-challenge-request.json b/schemas/authorization-challenge-request.json new file mode 100644 index 00000000..d39462d9 --- /dev/null +++ b/schemas/authorization-challenge-request.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://curveballjs.org/schemas/a12nserver/authorization-challenge-request.json", + "type": "object", + "title": "AuthorizationChallengeRequest", + "description": "Request body for the Authorization Challenge Request endpoint for first-party applications.", + "required": [], + "additionalProperties": false, + + "properties": { + "scope": { + "type": "string", + "description": "OAuth2 scope" + }, + "auth_session": { + "description": "If the client has started a login session, specify auth_session to continue the login process", + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + +} diff --git a/src/api-types.ts b/src/api-types.ts index 666d08ad..bb5e95e6 100644 --- a/src/api-types.ts +++ b/src/api-types.ts @@ -64,6 +64,28 @@ export interface App { * and run json-schema-to-typescript to regenerate this file. */ +/** + * Request body for the Authorization Challenge Request endpoint for first-party applications. + */ +export interface AuthorizationChallengeRequest { + /** + * OAuth2 scope + */ + scope?: string; + /** + * If the client has started a login session, specify auth_session to continue the login process + */ + auth_session?: string; + username?: string; + password?: string; +} +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + /** * This is the request body used by the HTML form submission for creating new groups */ diff --git a/src/app.ts b/src/app.ts index 7d4f24e4..b1293002 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,13 +5,10 @@ import accessLog from '@curveball/accesslog'; import mainMw from './main-mw.js'; import { init as initDb } from './database.js'; import { load } from './server-settings.js'; +import './env.js'; import { NAME, VERSION } from './version.js'; -import * as dotenv from 'dotenv'; - -dotenv.config(); - console.info('⚾ %s %s', NAME, VERSION); const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8531; diff --git a/src/database.ts b/src/database.ts index b869c83d..ab8ee100 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,10 +1,8 @@ /* eslint no-console: 0 */ import { Knex, default as knex } from 'knex'; import * as path from 'node:path'; -import * as dotenv from 'dotenv'; import { fileURLToPath } from 'node:url'; - -dotenv.config(); +import './env.js'; let settings: Knex.Config | null = null; const db: Knex = knex(getSettings()); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..72b4976e --- /dev/null +++ b/src/env.ts @@ -0,0 +1,18 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * This file exists to ensure that .env is loaded as early as possible. + * Anything that needs it should import it + */ +import * as dotenv from 'dotenv'; + +if (process.env.PUBLIC_URI === undefined) { + // If there's no PUBLIC_URI environment variable, it's a good indication + // that we may be missing a .env file. + // + // This is the only required environment variable. + dotenv.config({path: dirname(fileURLToPath(import.meta.url)) + '/../.env'}); +} else { + console.warn('/env.js was loaded twice?'); +} diff --git a/src/home/formats/markdown.ts b/src/home/formats/markdown.ts index 30ce60ff..337dd974 100644 --- a/src/home/formats/markdown.ts +++ b/src/home/formats/markdown.ts @@ -61,6 +61,8 @@ OAuth2 endpoints * Token endpoint * Authorization Server Metadata ([RFC8414][RFC8414]) * JSON Web Key Sets ([RFC7517][RFC7517]) +* Authorization Challenge Endpoint ([draft][draft-parecki-oauth-first-party-apps]) Experimental + Other API endpoints ------------------- @@ -75,6 +77,7 @@ _Version ${version}_ [RFC7662]: https://tools.ietf.org/html/rfc7662 "OAuth 2.0 Token Introspection" [RFC8414]: https://tools.ietf.org/html/rfc8414 "OAuth 2.0 Authorization Server Metadata" [RFC7517]: https://tools.ietf.org/html/rfc7517 "JSON Web Key (JWK)" +[draft-parecki-oauth-first-party-apps]: https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-00.html "OAuth 2.0 for First-Party Applications" `; }; diff --git a/src/knexfile.ts b/src/knexfile.ts index 34f88834..d92f68d4 100644 --- a/src/knexfile.ts +++ b/src/knexfile.ts @@ -1,15 +1,5 @@ -import { dirname } from 'node:path'; import { getSettings } from './database.js'; -import * as dotenv from 'dotenv'; -import { fileURLToPath } from 'node:url'; - -if (process.env.PUBLIC_URI === undefined) { - // If there's no PUBLIC_URI environment variable, it's a good indication - // that we may be missing a .env file. - // - // This is the only required environment variable. - dotenv.config({path: dirname(fileURLToPath(import.meta.url)) + '/../.env'}); -} +import './env.js'; const settings = getSettings(); diff --git a/src/login/controller/authorization-challenge.ts b/src/login/controller/authorization-challenge.ts new file mode 100644 index 00000000..24c7f339 --- /dev/null +++ b/src/login/controller/authorization-challenge.ts @@ -0,0 +1,59 @@ +import { Controller } from '@curveball/controller'; +import { Context } from '@curveball/kernel'; +import { getOAuth2ClientFromBasicAuth } from '../../app-client/service.js'; +import { UnauthorizedClient } from '../../oauth2/errors.js'; +import { UnsupportedMediaType } from '@curveball/http-errors'; +import { AuthorizationChallengeRequest } from '../../api-types.js'; +import * as loginService from '../service.js'; + +/** + * The authorization challenge controller is an implementation of OAuth 2.0 + * for First-Party Applications. + * + * This specification is currently a draft, and so this endpoint will also + * evolve as the specification evolves. + * + * The endpoint lets a first party, trusted app authenticate with an OAuth2 + * server and completely own UI. + * + * It's implemented as a series of requests and challenges until all + * challenges are met. For example, a first challenge may be a password, a + * second a TOTP token. Once there are no more challenges, a 'code' gets + * returned which may be exchanged for a 'token' on the token endpoint. + * + * https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-00.html + * + */ +class AuthorizationChallengeController extends Controller { + + async post(ctx: Context) { + + // We will only support Basic auth for now. + const client = await getOAuth2ClientFromBasicAuth(ctx); + + if (!client.allowedGrantTypes.includes('authorization_challenge')) { + throw new UnauthorizedClient('This client is not allowed to use the "authorization_challenge" oauth2 flow'); + } + + if (!ctx.request.is('application/x-www-form-urlencoded')) { + throw new UnsupportedMediaType('This endpoint requires thei request to use the "application/x-www-form-urlencoded" content-type'); + } + + ctx.request.validate('https://curveballjs.org/schemas/a12nserver/authorization-challenge-request.json'); + + const request = ctx.request.body; + + const session = await loginService.getSession(client, request); + const code = await loginService.challenge(client, session, request); + + ctx.response.body = { + authorization_code: code.code, + }; + + } + +} + + + +export default new AuthorizationChallengeController(); diff --git a/src/login/service.ts b/src/login/service.ts new file mode 100644 index 00000000..4cae4631 --- /dev/null +++ b/src/login/service.ts @@ -0,0 +1,299 @@ +import { OAuth2Client, Principal, PrincipalIdentity, User } from '../types.js'; +import { getSessionStore } from '../session-store.js'; +import { InvalidGrant, OAuth2Error } from '../oauth2/errors.js'; +import * as services from '../services.js'; +import { BadRequest, NotFound } from '@curveball/http-errors'; +import { AuthorizationChallengeRequest } from '../api-types.js'; +import { OAuth2Code } from '../oauth2/types.js'; + +type ChallengeRequest = AuthorizationChallengeRequest; + +type LoginSession = { + authSession: string; + appClientId: number; + + /** + * Unix timestamp (in seconds) when the login session expires + */ + expiresAt: number; + + /** + * User id + */ + principalId: number | null; + + /** + * Password was checked. + */ + passwordValid: boolean; + + /** + * List of OAuth2 scopes + */ + scope?: string[]; + + /** + * Internal marker. If something related to the session changed we'll + * set this to true to indicate the session store should be updated. + */ + dirty: boolean; + +}; + +type LoginSessionStage2 = LoginSession & { + + /** + * User id + */ + principalId: number; + + /** + * Password was checked. + */ + passwordValid: true; + +} + +/** + * How long until a login session expires (in seconds) + */ +const LOGIN_SESSION_EXPIRY = 60*20; + +export async function getSession(client: OAuth2Client, parameters: ChallengeRequest): Promise { + + if (parameters.auth_session) { + if (parameters.scope) { + throw new BadRequest('Currently you\'re only allowed to specify the scope parameter on the first authorization_challenge request'); + } + return continueLoginSession(client, parameters.auth_session); + } else { + return startLoginSession(client, parameters.scope?.split(' ')); + } + +} + + +export async function startLoginSession(client: OAuth2Client, scope?: string[]): Promise { + + const store = getSessionStore(); + const id: string = await store.newSessionId(); + + return { + authSession: id, + appClientId: client.id, + expiresAt: Math.floor(Date.now() / 1000) + LOGIN_SESSION_EXPIRY, + principalId: null, + passwordValid: false, + scope, + dirty: true, + }; + +} + +export async function continueLoginSession(client: OAuth2Client, authSession: string): Promise { + + const store = getSessionStore(); + const session: LoginSession|null = await store.get(authSession) as LoginSession|null; + + if (session === null) { + throw new InvalidGrant('Invalid auth_session'); + } + + if (session.appClientId != client.id) { + throw new InvalidGrant('The client you authenticated with did not start this login session'); + } + + return session; + +} + +export async function storeSession(session: LoginSession) { + + const store = getSessionStore(); + await store.set(session.authSession, session, session.expiresAt); + +} +async function deleteSession(session: LoginSession) { + + const store = getSessionStore(); + await store.delete(session.authSession); + +} + + + +/** + * Validate a login challenge request. + * + * If the challenge contained any correct values, they will be stored in the + * login session, and don't need to be re-submitted. + * + * If more credentials are needed or if any information is incorrect, an error + * will be thrown. + */ +export async function challenge(client: OAuth2Client, session: LoginSession, parameters: ChallengeRequest): Promise { + + try { + if (!session.principalId) { + if (parameters.username === undefined || parameters.username === undefined) { + throw new A12nLoginChallengeError( + session, + 'A username and password are required', + 'username-password', + false, + ); + + } + await challengeUsernamePassword( + session, + parameters.password!, + parameters.username!, + ); + + } + + + } finally { + + if (session.dirty) { + await storeSession(session); + session.dirty = false; + } + + } + + assertSessionStage2(session); + + const principalService = new services.principal.PrincipalService('insecure'); + const user = await principalService.findById(session.principalId, 'user'); + + await deleteSession(session); + + return await services.oauth2.generateAuthorizationCode({ + client, + principal: user, + scope: session.scope ?? [], + browserSessionId: session.authSession, + codeChallenge: null, + codeChallengeMethod: null, + nonce: null, + }); + +} + +async function challengeUsernamePassword(session: LoginSession, username: string, password: string): Promise { + + const principalService = new services.principal.PrincipalService('insecure'); + let user: Principal; + let identity: PrincipalIdentity; + try { + identity = await services.principalIdentity.findByUri('mailto:' + username); + } catch (err) { + if (err instanceof NotFound) { + throw new A12nLoginChallengeError( + session, + 'Incorrect username or password', + 'username-password', + true + ); + } else { + throw err; + } + + } + + try { + user = await principalService.findByIdentity(identity); + } catch (err) { + if (err instanceof NotFound) { + throw new A12nLoginChallengeError( + session, + 'Incorrect username or password', + 'username-password', + true + ); + } else { + throw err; + } + } + + if (user.type !== 'user') { + throw new A12nLoginChallengeError( + session, + 'Credentials are not associated with a user', + 'username-password', + true, + ); + } + + if (!await services.user.validatePassword(user, password)) { + throw new A12nLoginChallengeError( + session, + 'Incorrect username or password', + 'username-password', + true, + ); + } + + session.principalId = user.id; + session.passwordValid = true; + session.dirty = true; + + if (!user.active) { + + throw new A12nLoginChallengeError( + session, + 'This account is not active. Please contact support', + 'activate', + false, + ); + } + if (identity.verifiedAt === null) { + throw new A12nLoginChallengeError( + session, + 'Email is not verified', + 'verify-email', + true + ); + } + return user; +} + + +type ChallengeType = + | 'username-password' // We want a username and password + | 'activate' // Account is inactive. There's nothing the user can do. + | 'verify-email' // We recognized the email address, but it was never verified + +class A12nLoginChallengeError extends OAuth2Error { + + httpStatus = 400; + errorCode = 'a12n_login_challenge'; + + session: LoginSession; + userChallenge: ChallengeType; + wasFail: boolean; + + constructor(session: LoginSession, message: string, userChallenge: ChallengeType, wasFail: boolean) { + + super(message); + this.userChallenge = userChallenge; + this.wasFail = wasFail; + this.session = session; + + } + +} + +function assertSessionStage2(session: LoginSession): asserts session is LoginSessionStage2 { + + if (!session.principalId) { + throw new Error('Invalid state: missing principalId'); + } + if (!session.passwordValid) { + throw new Error('Invalid state: passwordValid was false'); + } + +} + + diff --git a/src/main-mw.ts b/src/main-mw.ts index a52d57da..628ca7b2 100644 --- a/src/main-mw.ts +++ b/src/main-mw.ts @@ -5,7 +5,6 @@ import links from '@curveball/links'; import problem from '@curveball/problem'; import session from '@curveball/session'; import validator from '@curveball/validator'; -import { RedisStore } from '@curveball/session-redis'; import { invokeMiddlewares, Middleware } from '@curveball/core'; import login from './middleware/login.js'; @@ -14,6 +13,7 @@ import routes from './routes.js'; import { getSetting } from './server-settings.js'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getSessionStore } from './session-store.js'; /** * The 'main middleware'. @@ -40,12 +40,7 @@ export default function (): Middleware { middlewares.push( session({ - store: process.env.REDIS_HOST ? new RedisStore({ - prefix: 'A12N-session', - clientOptions: { - url: process.env.REDIS_URI!, - }, - }) : 'memory', + store: getSessionStore(), cookieName: 'A12N', expiry: 60 * 60 * 24 * 7, }), diff --git a/src/oauth2/controller/authorize.ts b/src/oauth2/controller/authorize.ts index 8e6166e5..2b40a4f6 100644 --- a/src/oauth2/controller/authorize.ts +++ b/src/oauth2/controller/authorize.ts @@ -115,10 +115,10 @@ class AuthorizeController extends Controller { client: oauth2Client, principal: ctx.session.user, scope: params.scope, - codeChallenge: params.codeChallenge, - codeChallengeMethod: params.codeChallengeMethod, + codeChallenge: params.codeChallenge ?? null, + codeChallengeMethod: params.codeChallengeMethod ?? null, browserSessionId: ctx.sessionId!, - nonce: params.nonce, + nonce: params.nonce ?? null, }); const redirectParams: Record = { diff --git a/src/oauth2/errors.ts b/src/oauth2/errors.ts index b6d856d9..12901ec6 100644 --- a/src/oauth2/errors.ts +++ b/src/oauth2/errors.ts @@ -1,7 +1,21 @@ import { HttpError } from '@curveball/http-errors'; -interface OAuth2Error extends HttpError { - errorCode: string; +export abstract class OAuth2Error extends Error implements HttpError { + abstract errorCode: string; + abstract httpStatus: number; + + /** + * Returns the JSON response body for the OAuth2 error. + */ + serializeErrorBody() { + + return { + error: this.errorCode, + error_description: this.message, + }; + + } + } export function isOAuth2Error(err: any): err is OAuth2Error { @@ -14,7 +28,7 @@ export function isOAuth2Error(err: any): err is OAuth2Error { * The request is missing a required parameter, includes an invalid parameter * value, includes a parameter more than once or is otherwise malformed. */ -export class InvalidRequest extends Error implements OAuth2Error { +export class InvalidRequest extends OAuth2Error { httpStatus = 400; errorCode = 'invalid_request'; @@ -25,7 +39,7 @@ export class InvalidRequest extends Error implements OAuth2Error { * The client is not authorized to request an authorization code using this * method */ -export class UnauthorizedClient extends Error implements OAuth2Error { +export class UnauthorizedClient extends OAuth2Error { httpStatus = 403; errorCode = 'unauthorized_client'; @@ -35,7 +49,7 @@ export class UnauthorizedClient extends Error implements OAuth2Error { /** * The resource owner or authorization server denied the request */ -export class AccessDenied extends Error implements OAuth2Error { +export class AccessDenied extends OAuth2Error { httpStatus = 403; errorCode = 'access_denied'; @@ -46,7 +60,7 @@ export class AccessDenied extends Error implements OAuth2Error { * The authorization server does not support obtaining an authorization code * using this method */ -export class UnsupportedResponseType extends Error implements OAuth2Error { +export class UnsupportedResponseType extends OAuth2Error { httpStatus = 400; errorCode = 'unsupported_response_type'; @@ -56,7 +70,7 @@ export class UnsupportedResponseType extends Error implements OAuth2Error { /** * The requested scope is invalid, unknown or malformed */ -export class InvalidScope extends Error implements OAuth2Error { +export class InvalidScope extends OAuth2Error { httpStatus = 400; errorCode = 'invalid_scope'; @@ -73,7 +87,7 @@ export class InvalidScope extends Error implements OAuth2Error { * the "WWW-Authenticate" response header field matching the authentication * scheme used by the client. */ -export class InvalidClient extends Error implements OAuth2Error { +export class InvalidClient extends OAuth2Error { httpStatus = 401; errorCode = 'invalid_client'; @@ -86,7 +100,7 @@ export class InvalidClient extends Error implements OAuth2Error { * the redirection URI used in the authorization request, or was issued to * another client. */ -export class InvalidGrant extends Error implements OAuth2Error { +export class InvalidGrant extends OAuth2Error { httpStatus = 400; errorCode = 'invalid_grant'; @@ -96,7 +110,7 @@ export class InvalidGrant extends Error implements OAuth2Error { /** * The authorization grant type is not supported by the authorization server. */ -export class UnsupportedGrantType extends Error implements OAuth2Error { +export class UnsupportedGrantType extends OAuth2Error { httpStatus = 400; errorCode = 'unsupported_grant_type'; @@ -109,7 +123,7 @@ export class UnsupportedGrantType extends Error implements OAuth2Error { * Internal Server Error HTTP status code cannot be returned to the client via * an HTTP redirect.) */ -export class ServerError extends Error implements OAuth2Error { +export class ServerError extends OAuth2Error { httpStatus = 500; errorCode = 'server_error'; @@ -122,7 +136,7 @@ export class ServerError extends Error implements OAuth2Error { * needed because a 503 Service Unavailable HTTP status code cannot be returned * to the client via an HTTP redirect.) */ -export class TemporarilyUnavailable extends Error implements OAuth2Error { +export class TemporarilyUnavailable extends OAuth2Error { httpStatus = 503; errorCode = 'temporarily_unavailable'; diff --git a/src/oauth2/oauth2-error-handler.ts b/src/oauth2/oauth2-error-handler.ts index 6237216e..65dde474 100644 --- a/src/oauth2/oauth2-error-handler.ts +++ b/src/oauth2/oauth2-error-handler.ts @@ -13,10 +13,7 @@ const oauth2ErrorHandler: Middleware = async (ctx, next) => { ctx.status = err.httpStatus; ctx.response.type = 'application/json'; - ctx.response.body = { - error: err.errorCode, - error_description: err.message, - }; + ctx.response.body = err.serializeErrorBody(); } else { // Let someone else deal with it diff --git a/src/oauth2/service.ts b/src/oauth2/service.ts index b1c3f8e6..1c457548 100644 --- a/src/oauth2/service.ts +++ b/src/oauth2/service.ts @@ -457,10 +457,10 @@ type GenerateAuthorizationCodeOptions = { client: OAuth2Client; principal: User; scope: string[]; - codeChallenge: string|undefined; - codeChallengeMethod: CodeChallengeMethod|undefined; + codeChallenge: string|null; + codeChallengeMethod: CodeChallengeMethod|null; + nonce: string | null; browserSessionId: string; - nonce: string | undefined; } /** * This function is used for the authorization_code grant flow. @@ -626,6 +626,7 @@ function grantTypeIdInfo(grantType: number|null): [Exclude