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:
Sylvain Duchesne
2026-10-12 13:12:38 +02:00
parent 18fc71ff36
commit a61c8704c9
13 changed files with 882 additions and 827 deletions
+59 -3
View File
@@ -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']}`);
}
}
}