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