Reconcile external identity on Admin API requests #50

Open
opened 2026-07-24 22:49:50 +00:00 by frank · 0 comments
Owner

Goal

Prevent browser-authenticated Admin API requests from using a Ghost session that no longer matches the current external identity.

Requirements

  • Update the Admin API authentication path.
  • Run external-identity reconciliation before cookie-session authentication.
  • Skip external-identity reconciliation when a valid Admin API or staff token is used.
  • Clear and reject the Ghost session when the external identity is missing, unauthorized, unknown, or mismatched.
  • Rotate the Ghost session when switching between two valid staff users.
  • Do not require browser SSO credentials for public login, verification, or integration-token endpoints.

Acceptance criteria

  • Browser-authenticated Admin API requests verify the current external identity.
  • A stale Ghost cookie cannot bypass external authorization.
  • Switching to an unknown external user returns an authorization error.
  • Switching between valid Ghost users rotates and updates the session.
  • Integration and staff tokens continue to work.
  • Public authentication endpoints remain accessible.
  • End-to-end tests cover access removal and user switching.

Further information and implementation sketch

Ghost mounts the API path separately from the /ghost Admin application. Browser API requests can therefore authenticate directly through the Ghost session middleware.

The Admin API authentication chain currently resembles:

const authenticate = {
    authenticateAdminApi: [
        apiKeyAuth.admin.authenticate,
        session.authenticate
    ],

    authenticateAdminApiWithUrl: [
        apiKeyAuth.admin.authenticateWithUrl
    ]
};

A possible design is to insert external-identity reconciliation after API-key authentication and before Ghost session authentication:

const authenticate = {
    authenticateAdminApi: [
        apiKeyAuth.admin.authenticate,
        session.reconcileExternalIdentity,
        session.authenticate
    ],

    authenticateAdminApiWithUrl: [
        apiKeyAuth.admin.authenticateWithUrl
    ]
};

The reconciliation middleware should skip requests already authenticated with an API key:

async function reconcileExternalIdentity(req, res, next) {
    if (req.api_key) {
        return next();
    }

    return createSessionFromToken()(req, res, next);
}

This should use the same strict SSO exchange logic rather than duplicating identity parsing and lookup.

The middleware must distinguish between:

Valid external identity matching the session
→ keep the current session

Valid external identity for another Ghost staff user
→ rotate the session and assign the new user

Missing, unauthorized, or unknown external identity
→ clear the Ghost session and reject the request

Admin API or staff token
→ skip browser SSO reconciliation

Ghost already contains logic for regenerating and assigning a verified session:

async function rotateAndAssignVerifiedUserToSession({
    req,
    user,
    ip
}) {
    await new Promise((resolve, reject) => {
        req.session.regenerate((err) => {
            if (err) {
                reject(err);
                return;
            }

            resolve();
        });
    });

    await assignVerifiedUserToSession({
        session: req.session,
        user,
        origin: getOriginOfRequest(req),
        ip
    });
}

That function should be used when the current Ghost session belongs to a different user than the resolved external identity. The session should not be rotated on every request.

A more explicit implementation could let the SSO exchange middleware inspect the current session user:

const currentUser = await sessionService.getUserForSession(req, res);

if (currentUser && currentUser.id !== user.id) {
    await sessionService.rotateAndAssignVerifiedUserToSession({
        req,
        user,
        ip: req.ip
    });
} else {
    await sessionService.createVerifiedSessionForUser(req, res, user);
}

The exact placement should ensure that:

  • SSO reconciliation runs for browser requests to private Admin API endpoints;
  • API-key authentication remains independent;
  • public endpoints such as session creation and verification are not accidentally protected by strict SSO middleware.

Relevant implementation:

  • ghost/core/core/server/services/auth/authenticate.js
  • ghost/core/core/server/services/auth/session/index.js
  • ghost/core/core/server/services/auth/session/session-service.js
  • ghost/core/core/server/web/api/endpoints/admin/middleware.js
  • ghost/core/test/e2e-api/admin/sso.test.js
## Goal Prevent browser-authenticated Admin API requests from using a Ghost session that no longer matches the current external identity. ## Requirements * Update the Admin API authentication path. * Run external-identity reconciliation before cookie-session authentication. * Skip external-identity reconciliation when a valid Admin API or staff token is used. * Clear and reject the Ghost session when the external identity is missing, unauthorized, unknown, or mismatched. * Rotate the Ghost session when switching between two valid staff users. * Do not require browser SSO credentials for public login, verification, or integration-token endpoints. ## Acceptance criteria * [ ] Browser-authenticated Admin API requests verify the current external identity. * [ ] A stale Ghost cookie cannot bypass external authorization. * [ ] Switching to an unknown external user returns an authorization error. * [ ] Switching between valid Ghost users rotates and updates the session. * [ ] Integration and staff tokens continue to work. * [ ] Public authentication endpoints remain accessible. * [ ] End-to-end tests cover access removal and user switching. ## Further information and implementation sketch Ghost mounts the API path separately from the `/ghost` Admin application. Browser API requests can therefore authenticate directly through the Ghost session middleware. The Admin API authentication chain currently resembles: ```js const authenticate = { authenticateAdminApi: [ apiKeyAuth.admin.authenticate, session.authenticate ], authenticateAdminApiWithUrl: [ apiKeyAuth.admin.authenticateWithUrl ] }; ``` A possible design is to insert external-identity reconciliation after API-key authentication and before Ghost session authentication: ```js const authenticate = { authenticateAdminApi: [ apiKeyAuth.admin.authenticate, session.reconcileExternalIdentity, session.authenticate ], authenticateAdminApiWithUrl: [ apiKeyAuth.admin.authenticateWithUrl ] }; ``` The reconciliation middleware should skip requests already authenticated with an API key: ```js async function reconcileExternalIdentity(req, res, next) { if (req.api_key) { return next(); } return createSessionFromToken()(req, res, next); } ``` This should use the same strict SSO exchange logic rather than duplicating identity parsing and lookup. The middleware must distinguish between: ```text Valid external identity matching the session → keep the current session Valid external identity for another Ghost staff user → rotate the session and assign the new user Missing, unauthorized, or unknown external identity → clear the Ghost session and reject the request Admin API or staff token → skip browser SSO reconciliation ``` Ghost already contains logic for regenerating and assigning a verified session: ```js async function rotateAndAssignVerifiedUserToSession({ req, user, ip }) { await new Promise((resolve, reject) => { req.session.regenerate((err) => { if (err) { reject(err); return; } resolve(); }); }); await assignVerifiedUserToSession({ session: req.session, user, origin: getOriginOfRequest(req), ip }); } ``` That function should be used when the current Ghost session belongs to a different user than the resolved external identity. The session should not be rotated on every request. A more explicit implementation could let the SSO exchange middleware inspect the current session user: ```js const currentUser = await sessionService.getUserForSession(req, res); if (currentUser && currentUser.id !== user.id) { await sessionService.rotateAndAssignVerifiedUserToSession({ req, user, ip: req.ip }); } else { await sessionService.createVerifiedSessionForUser(req, res, user); } ``` The exact placement should ensure that: * SSO reconciliation runs for browser requests to private Admin API endpoints; * API-key authentication remains independent; * public endpoints such as session creation and verification are not accidentally protected by strict SSO middleware. Relevant implementation: * `ghost/core/core/server/services/auth/authenticate.js` * `ghost/core/core/server/services/auth/session/index.js` * `ghost/core/core/server/services/auth/session/session-service.js` * `ghost/core/core/server/web/api/endpoints/admin/middleware.js` * `ghost/core/test/e2e-api/admin/sso.test.js`
frank added this to the Forward Auth mode milestone 2026-07-24 22:49:50 +00:00
Sign in to join this conversation.
No description provided.