Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12
This commit is contained in:
@@ -1,106 +0,0 @@
|
||||
const { MIME_TYPES } = require('@semapps/mime-types');
|
||||
|
||||
// Version-skew compatibility shim for the app registration handshake.
|
||||
//
|
||||
// `@activitypods/app` 2.3.2 (app side) and the local pod provider (HEAD) disagree on where
|
||||
// an AccessGrant records the access needs it satisfies. The app-side `app-registrations.verify`
|
||||
// tests `accessGrant['interop:satisfiesAccessNeed']` directly on the AccessGrant — the umbrella
|
||||
// grant the pod lists in `interop:hasAccessGrant`. The pod provider now writes
|
||||
// `interop:satisfiesAccessNeed` on the DataGrants instead, reached through
|
||||
// AccessGrant -> `interop:hasDataGrant` -> DataGrant. The original test therefore never matches,
|
||||
// verify returns `accessNeedsSatisfied: false`, the inbox processor throws
|
||||
// "One or more required access needs have not been granted", the local AppRegistration is never
|
||||
// stored, and the consent flow loops back to `/login?register_app=true`.
|
||||
//
|
||||
// This shim reimplements the original `verify` verbatim, with the access-need test resolving
|
||||
// through DataGrants (falling back to the grant itself when it carries no DataGrant, so it
|
||||
// tolerates both the old and the new pod-side shape). It is installed as a broker middleware
|
||||
// because the target action lives inside the `@activitypods/app` package: the service itself is
|
||||
// registered by that package, so the handler cannot be overridden by a normal mixin. The
|
||||
// middleware is added to `broker.middlewares` in `created()`, before the services that expose
|
||||
// `app-registrations.verify` are constructed, so it wraps every local action; only
|
||||
// `app-registrations.verify` is intercepted.
|
||||
//
|
||||
// This is a workaround for platform drift, not a Festipod concern: the proper fix belongs
|
||||
// upstream in `@activitypods/app`, and is reported to the provider rather than worked around there.
|
||||
|
||||
const arrayOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
module.exports = {
|
||||
name: 'apods-verify-compat',
|
||||
created() {
|
||||
this.broker.middlewares.add({
|
||||
name: 'ApodsVerifyCompat',
|
||||
localAction(next, action) {
|
||||
if (action.name !== 'app-registrations.verify') return next;
|
||||
|
||||
return async ctx => {
|
||||
const { appRegistrationUri } = ctx.params;
|
||||
|
||||
// Use local context to get all data
|
||||
const jsonContext = await ctx.call('jsonld.context.get');
|
||||
|
||||
// Get from remote server, not local cache, to get latest version
|
||||
const appRegistration = await ctx.call('ldp.remote.getNetwork', {
|
||||
resourceUri: appRegistrationUri,
|
||||
jsonContext
|
||||
});
|
||||
|
||||
const accessGrants = await Promise.all(
|
||||
arrayOf(appRegistration['interop:hasAccessGrant']).map(grantUri =>
|
||||
ctx.call('ldp.remote.get', {
|
||||
resourceUri: grantUri,
|
||||
jsonContext,
|
||||
accept: MIME_TYPES.JSON
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Resolve each grant's DataGrants, where the pod provider now records the
|
||||
// satisfied access needs (AccessGrant -> interop:hasDataGrant -> DataGrant).
|
||||
const grantsWithDataGrants = await Promise.all(
|
||||
accessGrants.map(async grant => ({
|
||||
grant,
|
||||
dataGrants: await Promise.all(
|
||||
arrayOf(grant['interop:hasDataGrant']).map(dataGrantUri =>
|
||||
ctx.call('ldp.remote.get', {
|
||||
resourceUri: dataGrantUri,
|
||||
jsonContext,
|
||||
accept: MIME_TYPES.JSON
|
||||
})
|
||||
)
|
||||
)
|
||||
}))
|
||||
);
|
||||
|
||||
const satisfiesAccessNeed = accessNeedUri =>
|
||||
grantsWithDataGrants.some(
|
||||
({ grant, dataGrants }) =>
|
||||
dataGrants.some(dataGrant => dataGrant['interop:satisfiesAccessNeed'] === accessNeedUri) ||
|
||||
grant['interop:satisfiesAccessNeed'] === accessNeedUri
|
||||
);
|
||||
|
||||
// Get required access need group(s)
|
||||
const filteredContainer = await ctx.call('access-needs-groups.list', {
|
||||
filters: {
|
||||
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
|
||||
},
|
||||
accept: MIME_TYPES.JSON
|
||||
});
|
||||
const requiredAccessNeedGroups = arrayOf(filteredContainer['ldp:contains']);
|
||||
|
||||
// Return true if all access needs and special rights of the required AccessNeedGroup(s) are granted
|
||||
const accessNeedsSatisfied = requiredAccessNeedGroups.every(
|
||||
group =>
|
||||
arrayOf(group['interop:hasAccessNeed']).every(accessNeedUri => satisfiesAccessNeed(accessNeedUri)) &&
|
||||
arrayOf(group['interop:hasSpecialRights']).every(specialRightUri =>
|
||||
appRegistration['apods:hasSpecialRights'].some(sr => sr === specialRightUri)
|
||||
)
|
||||
);
|
||||
|
||||
return { accessNeedsSatisfied, appRegistration, accessGrants };
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -68,7 +68,7 @@ module.exports = {
|
||||
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
|
||||
// mixin's `started()`, i.e. long before any request is served.
|
||||
get: {
|
||||
handler(ctx) {
|
||||
async handler(ctx) {
|
||||
// moleculer-web exposes the raw request only when the route alias sets
|
||||
// passReqResToParams (see services/core/core.service.js). Internal calls
|
||||
// (ctx.call('app.get')) have no `$req`, so they always get the actor.
|
||||
@@ -78,17 +78,73 @@ module.exports = {
|
||||
const isOidcClientIdDocument =
|
||||
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
|
||||
|
||||
// `this.appActor` is set at the very END of the mixin's started(). During boot,
|
||||
// this action is already routable (the api gateway is up before the services
|
||||
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
|
||||
// actor over HTTP through this very route — waiting on an action that waits on the
|
||||
// boot it blocks. Read the actor straight from the triplestore in that window.
|
||||
let actor = this.appActor;
|
||||
if (!actor) {
|
||||
try {
|
||||
actor = await ctx.call('ldp.resource.get', {
|
||||
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
} catch (e) {
|
||||
actor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOidcClientIdDocument) {
|
||||
// The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app`
|
||||
// serve this document (the configured client_id is the actor uri; `/app` remains
|
||||
// a valid alias), so echo the request path rather than hardcoding one.
|
||||
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path
|
||||
// survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, 'app'),
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
};
|
||||
}
|
||||
|
||||
return this.appActor;
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
},
|
||||
async started() {
|
||||
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
|
||||
// actor in an after.post hook of the group's creation, but the hook races the actor's own
|
||||
// creation on a cold start (both run inside the broker's parallel started() phase): the
|
||||
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
|
||||
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
|
||||
// needs from that link — an empty consent registers the app with zero grants and the
|
||||
// register flow loops. Idempotent: only patches when the link is missing.
|
||||
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
|
||||
const actor = await this.broker.call('ldp.resource.get', {
|
||||
resourceUri: actorUri,
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
const link = actor && actor['interop:hasAccessNeedGroup'];
|
||||
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
|
||||
if (!hasLink) {
|
||||
const groups = await this.broker.call('access-needs-groups.list', {
|
||||
filters: {
|
||||
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
|
||||
},
|
||||
accept: 'application/ld+json'
|
||||
});
|
||||
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
|
||||
if (group) {
|
||||
await this.broker.call('actors.attachAccessNeedGroup', {
|
||||
accessNeedGroupUri: group.id || group['@id']
|
||||
});
|
||||
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const urlJoin = require('url-join');
|
||||
const CONFIG = require('../config/config');
|
||||
|
||||
// Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual
|
||||
// personality, discriminated by the request's `JsonLdContext` header — same contract as
|
||||
// the `/app` route (see app.service.js for the full story), but implemented as a
|
||||
// standalone service with no dependency on the `app` service.
|
||||
//
|
||||
// WHY A SEPARATE SERVICE: the Solid-OIDC client_id IS the actor uri (the pod provider's
|
||||
// auth-agent only returns the `Link: rel="interop:registeredAgent"` header when the
|
||||
// requesting app's identity — the token's azp, i.e. this client_id — matches the
|
||||
// registration's `registeredAgent`, which the pod stores under the actor uri). The
|
||||
// adapter therefore fetches THIS path as the client metadata document. Routing the path
|
||||
// to `app.get` deadlocked boot: `@activitypods/app`'s `awaitCreateComplete` polls the
|
||||
// actor over HTTP while the `app` service is still starting, and an unregistered action
|
||||
// 503s the poll it waits on. This service has no `started()` work and no dependencies,
|
||||
// so it is routable long before the `app` service exists.
|
||||
module.exports = {
|
||||
name: 'client-id-doc',
|
||||
actions: {
|
||||
get: {
|
||||
async handler(ctx) {
|
||||
const req = ctx.params && ctx.params.$req;
|
||||
const headers = (req && req.headers) || {};
|
||||
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
|
||||
const isOidcClientIdDocument =
|
||||
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
|
||||
|
||||
if (isOidcClientIdDocument) {
|
||||
// The client_id must equal the fetched URI exactly. `req.url` is rewritten to
|
||||
// the alias remainder by moleculer-web; the raw path survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
};
|
||||
}
|
||||
|
||||
return await ctx.call('ldp.resource.get', {
|
||||
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -39,6 +39,24 @@ module.exports = {
|
||||
json: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/as/actor/app',
|
||||
aliases: {
|
||||
'GET /': {
|
||||
// Served by a standalone service (not `app.get`): the Solid-OIDC client_id
|
||||
// IS the actor uri, and routing this to `app.get` deadlocked boot (the app
|
||||
// service's own startup polls this path over HTTP). See client-id-doc.service.js.
|
||||
action: 'client-id-doc.get',
|
||||
passReqResToParams: true
|
||||
}
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
bodyParsers: {
|
||||
json: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/catalog',
|
||||
aliases: {
|
||||
|
||||
Reference in New Issue
Block a user