diff --git a/controller/src/classes/OdrlController.ts b/controller/src/classes/OdrlController.ts index bd90c4e7..b603a546 100644 --- a/controller/src/classes/OdrlController.ts +++ b/controller/src/classes/OdrlController.ts @@ -1,4 +1,3 @@ -import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; import { BaseSubject, Index, Permission, Resources } from "../types"; import { IController, IInboxConstructor, IStore, IStoreConstructor, SubjectConfig, SubjectConfigs, SubjectKey, SubjectType } from "../types/modules"; import { type AccessRequest as AccessRequestObject, Policy, Rule, RuleUpdate } from "../types/modules"; @@ -57,23 +56,21 @@ export class ODRLController { if (updates.length === 0) return; - const webId = getDefaultSession().info.webId; - if (!webId) throw new Error("User not logged in"); const service = new ODRLPolicyService(this.authorizationServerURL); - const store = await service.fetchPolicies(webId); + const store = await service.fetchPolicies(); const interpreter = new PolicyInterpreter(); const allPolicies = interpreter.storeToPolicies(store); const policiesMap = new Map(allPolicies.map(p => [p.id, p])); const modifiedPolicyIds = new Set(); - + for (const update of updates) { if (!update.policyId){ if(update.updateType == 'add'){ @@ -88,7 +85,7 @@ export class ODRLController { const policy = policiesMap.get(policyId)!; if(policy.rules.length == 0){ - await service.deletePolicy(webId, policyId); + await service.deletePolicy(policyId); } else{ // Convert JS Policy object back to Turtle format - const turtleText = interpreter.policyToTurtle(webId, policy); - await service.putPolicy(webId, policyId, turtleText); + const turtleText = interpreter.policyToTurtle(policy); + await service.putPolicy(policyId, turtleText); } }); @@ -133,11 +130,7 @@ export class ODRLController { - const webId = getDefaultSession().info.webId; - if (!webId) { - throw new Error("User not logged in"); - } - const store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(webId); + const store = await new ODRLPolicyService(this.authorizationServerURL).fetchPolicies(); return new PolicyInterpreter().storeToPolicies(store, resourceUrl); } @@ -160,20 +153,17 @@ export class ODRLController { - const webid = getDefaultSession().info.webId!; - permission.accessRequest.requestingParty = webid; - await new ODRLAccessRequestService(this.authorizationServerURL).requestAccess(permission.accessRequest); + return new ODRLAccessRequestService(this.authorizationServerURL).requestAccess(permission.accessRequest); } async handleAccessRequest(requestId: string, status: 'accepted' | 'denied'): Promise { - const webid = getDefaultSession().info.webId!; - await new ODRLAccessRequestService(this.authorizationServerURL).acceptOrDenyAccess(requestId, webid, status); + return new ODRLAccessRequestService(this.authorizationServerURL).acceptOrDenyAccess(requestId, status); } async getAccessRequests(): Promise<{ asRequestingParty: AccessRequestObject[]; asResourceOwner: AccessRequestObject[]; }> { - return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(getDefaultSession().info.webId!); + return new ODRLAccessRequestService(this.authorizationServerURL).retrieveAccessRequests(); } -} \ No newline at end of file +} diff --git a/controller/src/classes/permissionManager/odrl/PublicManager.ts b/controller/src/classes/permissionManager/odrl/PublicManager.ts index 970a63fb..bdd08fc3 100644 --- a/controller/src/classes/permissionManager/odrl/PublicManager.ts +++ b/controller/src/classes/permissionManager/odrl/PublicManager.ts @@ -6,8 +6,8 @@ import { ODRLPermissionManager } from "./OdrlPermissionManager"; export class PublicManager>> extends ODRLPermissionManager implements IPermissionManager { //. NOTE: Currently, it doesn't do any recursive permission setting on containers - async createPermissions>(resource: string, subject: T[K], permissions: Permission[]): Promise { - await new ODRLPolicyService(this.authorizationServerURL).insertActionRule(resource, permissions) + async createPermissions>(resource: string, subject: T[K], permissions: Permission[], owner: string): Promise { + await new ODRLPolicyService(this.authorizationServerURL).insertActionRule(resource, permissions, owner) } async deletePermissions>(resource: string, subject: T[K], permissions: Permission[]) { diff --git a/controller/src/classes/permissionManager/odrl/WebIdManager.ts b/controller/src/classes/permissionManager/odrl/WebIdManager.ts index ab298e80..40b16996 100644 --- a/controller/src/classes/permissionManager/odrl/WebIdManager.ts +++ b/controller/src/classes/permissionManager/odrl/WebIdManager.ts @@ -6,8 +6,8 @@ import { ODRLPolicyService } from "../../utils/OdrlPolicyService"; export class WebIdManager>> extends ODRLPermissionManager implements IPermissionManager { // Create an action for this resource and this subject with the given permissions - async createPermissions>(resource: string, subject: T[K], permissions: Permission[]): Promise { - await new ODRLPolicyService(this.authorizationServerURL).insertActionRule(resource, permissions, subject.selector!.url); + async createPermissions>(resource: string, subject: T[K], permissions: Permission[], owner: string): Promise { + await new ODRLPolicyService(this.authorizationServerURL).insertActionRule(resource, permissions, subject.selector!.url, owner); } async deletePermissions>(resource: string, subject: T[K], permissions: Permission[]) { diff --git a/controller/src/classes/utils/Authentication.ts b/controller/src/classes/utils/Authentication.ts new file mode 100644 index 00000000..084d14d5 --- /dev/null +++ b/controller/src/classes/utils/Authentication.ts @@ -0,0 +1,54 @@ +export interface AuthenticationContext { + accessToken: string; + identifier: string; +} + +/** + * Normalizes an OIDC subject identifier to an IRI. + * Solid WebIDs are already IRIs; plain OIDC `sub` values (e.g. a username or UUID) + * are prepended with the server-side base to match how the policy AS stores them. + * Update this base when the server-side convention changes. + */ +const NON_IRI_IDENTIFIER_BASE = 'http://example.com/id/'; +const IRI_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/; + +export function normalizeIdentifierToIri(identifier: string): string { + if (IRI_PATTERN.test(identifier)) { + return identifier; + } + return `${NON_IRI_IDENTIFIER_BASE}${encodeURIComponent(identifier)}`; +} + +let authContext: AuthenticationContext | undefined; + +export function setAuthenticationContext(context: AuthenticationContext): void { + authContext = { + ...context, + identifier: normalizeIdentifierToIri(context.identifier), + }; +} + +export function clearAuthenticationContext(): void { + authContext = undefined; +} + +function requireAuthenticationContext(): AuthenticationContext { + if (!authContext) { + throw new Error('User not logged in'); + } + return authContext; +} + +export function authenticatedFetch(url: string, options?: RequestInit): Promise { + const { accessToken } = requireAuthenticationContext(); + const headers = new Headers(options?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + return fetch(url, { + ...options, + headers, + }); +} + +export function getLoggedInIdentifier(): string { + return requireAuthenticationContext().identifier; +} diff --git a/controller/src/classes/utils/OdrlAccessRequestService.ts b/controller/src/classes/utils/OdrlAccessRequestService.ts index c449141a..5c1408b7 100644 --- a/controller/src/classes/utils/OdrlAccessRequestService.ts +++ b/controller/src/classes/utils/OdrlAccessRequestService.ts @@ -1,28 +1,27 @@ +import { authenticatedFetch, getLoggedInIdentifier } from './Authentication'; import { AccessRequest } from "@/types/modules"; import { QueryEngine } from "@comunica/query-sparql"; import { Parser, Store } from "n3"; import { v4 as uuid } from 'uuid'; export class ODRLAccessRequestService { - + private readonly queryEngine = new QueryEngine(); private readonly parser = new Parser({ format: 'text/turtle' }); - + constructor( private readonly authorizationServerURL: string ) {} /** - * Place a POST request to create an access request to an UMA backend - * @param accessRequest - all infromation regarding an access request + * Place a POST request to create an access request to a UMA backend + * @param accessRequest - all information regarding an access request */ public requestAccess = async (accessRequest: AccessRequest): Promise => { - const response = await fetch( + const response = await authenticatedFetch( `${this.authorizationServerURL}/requests`, { method: 'POST', - headers: { - 'authorization': `WebID ${encodeURIComponent(accessRequest.requestingParty)}` - }, body: await this.accessRequestToJson(accessRequest) + body: await this.accessRequestToJson(accessRequest) } ); @@ -32,13 +31,13 @@ export class ODRLAccessRequestService { /** * Transform accessrequest to required JSON format * @param accessRequest - all information regarding an access request - * @returns + * @returns */ private accessRequestToJson = async (accessRequest: AccessRequest): Promise => { const payload: any = { resource_id: accessRequest.target, - resource_scopes: - accessRequest.actions.map(action => + resource_scopes: + accessRequest.actions.map(action => action.startsWith('http') ? action : `http://www.w3.org/ns/odrl/2/${action}` ) }; @@ -65,19 +64,16 @@ export class ODRLAccessRequestService { /** * Place a PATCH request to update an access request to an UMA backend * @param accessRequestID - ID of the access request to update - * @param resourceOwner - user credentials of the resource owner * @param status - new status for the update, must either be 'accepted' or 'denied' */ public acceptOrDenyAccess = async ( accessRequestID: string, - resourceOwner: string, status: 'accepted' | 'denied' ): Promise => { - const response = await fetch( + const response = await authenticatedFetch( `${this.authorizationServerURL}/requests/${encodeURIComponent(accessRequestID)}`, { method: 'PATCH', headers: { - 'authorization': `WebID ${encodeURIComponent(resourceOwner)}`, 'content-type': 'application/json' }, body: JSON.stringify({ status: status }) } @@ -88,16 +84,12 @@ export class ODRLAccessRequestService { /** * Retrieve all access requests related to the given resource owner or requesting party - * @param resourceOwnerOrRequestingPartyID - ID of the resource owner or requesting party */ - public retrieveAccessRequests = async (resourceOwnerOrRequestingPartyID: string): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => { + public retrieveAccessRequests = async (): Promise<{ asRequestingParty: AccessRequest[], asResourceOwner: AccessRequest[] }> => { const [ requestsResponse, policiesResponse ] = await Promise.all( - ['/requests', '/policies'].map((endpoint) => fetch( + ['/requests', '/policies'].map((endpoint) => authenticatedFetch( `${this.authorizationServerURL}${endpoint}`, { method: 'GET', - headers: { - 'authorization': `WebID ${encodeURIComponent(resourceOwnerOrRequestingPartyID)}` - } } )) ); @@ -113,12 +105,13 @@ export class ODRLAccessRequestService { const requestsStore = new Store(this.parser.parse(requestsText)); const policiesStore = new Store(this.parser.parse(policiesText)); + const id = getLoggedInIdentifier(); const requestingPartyBindings = await this.queryEngine.queryBindings( - this.accessRequestForRequestingParty(resourceOwnerOrRequestingPartyID), { sources: [requestsStore] } + this.accessRequestForRequestingParty(id), { sources: [requestsStore] } ); const resourceOwnerBindings = await this.queryEngine.queryBindings( - this.accessRequestForResourceOwner(resourceOwnerOrRequestingPartyID), { sources: [requestsStore, policiesStore] } + this.accessRequestForResourceOwner(id), { sources: [requestsStore, policiesStore] } ); return { @@ -128,9 +121,9 @@ export class ODRLAccessRequestService { } /** - * Transform raw bindings to AccessRequest objects - * @param bindings - * @returns + * Transform raw bindings to AccessRequest objects + * @param bindings + * @returns */ private bindingsToAccessRequest = async (bindings: any): Promise => { const requestsMap = new Map(); @@ -202,8 +195,8 @@ export class ODRLAccessRequestService { /** * Fetches all access requests submitted by a given WebId * Returns a SPARQL query string - * @param requestingPartyID - * @returns + * @param requestingPartyID + * @returns */ private readonly accessRequestForRequestingParty = (requestingPartyID: string): string => ` PREFIX ex: @@ -234,8 +227,13 @@ export class ODRLAccessRequestService { /** * Fetches all access requests controlled by a given WebId * Returns a SPARQL query string - * @param resourceOwnerID - * @returns + * + * An ID being the resource owner is determined by there being a policy owned by this ID targeting this resource. + * If there is no policy yet for this resource, + * this function will not be able to determine that the given ID is the owner. + * + * @param resourceOwnerID + * @returns */ private readonly accessRequestForResourceOwner = (resourceOwnerID: string): string => ` PREFIX ex: diff --git a/controller/src/classes/utils/OdrlPolicyService.ts b/controller/src/classes/utils/OdrlPolicyService.ts index 7b2ef7d1..986be955 100644 --- a/controller/src/classes/utils/OdrlPolicyService.ts +++ b/controller/src/classes/utils/OdrlPolicyService.ts @@ -1,15 +1,15 @@ -import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; +import { authenticatedFetch } from './Authentication'; import { Permission } from "../../types"; import { ODRL, PolicyParser } from "./PolicyParser"; import { DataFactory } from "n3"; const { namedNode } = DataFactory; -export const UMA_URL = (authorizationServerURL: string, encodedId: string = "") => +export const UMA_URL = (authorizationServerURL: string, encodedId: string = "") => `${authorizationServerURL}/policies${encodedId}`; export class ODRLPolicyService { private readonly authorizationServerURL: string; - constructor(authorizationServerURL: string) { + constructor(authorizationServerURL: string) { this.authorizationServerURL = authorizationServerURL; } @@ -24,11 +24,10 @@ export class ODRLPolicyService { return result; } - public async fetchPolicies(webId: string) { + public async fetchPolicies() { // Get all our policies - const response = await fetch(UMA_URL(this.authorizationServerURL), { + const response = await authenticatedFetch(UMA_URL(this.authorizationServerURL), { headers: { - "Authorization": `WebID ${encodeURIComponent(webId)}`, "Accept": "text/turtle" } }); @@ -42,11 +41,10 @@ export class ODRLPolicyService { return parser.parseText(turtleText); } - public async fetchOnePolicy(webId: string, policyId: string) { + public async fetchOnePolicy(policyId: string) { // Get all our policies - const response = await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + const response = await authenticatedFetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { headers: { - "Authorization": `WebID ${encodeURIComponent(webId)}`, "Accept": "text/turtle" } }); @@ -58,11 +56,10 @@ export class ODRLPolicyService { return parser.parseText(turtleText); } - public async postPolicy(webId: string, body: string) { - await fetch(UMA_URL(this.authorizationServerURL), { + public async postPolicy(body: string) { + await authenticatedFetch(UMA_URL(this.authorizationServerURL), { method: 'POST', headers: { - 'Authorization': `WebID ${encodeURIComponent(webId)}`, 'Content-type': 'text/turtle' // 'Content-type': 'application/sparql-update' }, @@ -70,31 +67,26 @@ export class ODRLPolicyService { }) } - public async putPolicy(webId: string, policyId: string, body: string) { - await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + public async putPolicy(policyId: string, body: string) { + await authenticatedFetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { method: 'PUT', headers: { - 'Authorization': `WebID ${encodeURIComponent(webId)}`, 'Content-type': 'text/turtle' }, body: body }) } - public async deletePolicy(webId: string, policyId: string) { - await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + public async deletePolicy(policyId: string) { + await authenticatedFetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { method: 'DELETE', - headers: { - 'Authorization': `WebID ${encodeURIComponent(webId)}`, - } }) } - public async patchPolicy(webId: string, policyId: string, body: string) { - await fetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { + public async patchPolicy(policyId: string, body: string) { + await authenticatedFetch(UMA_URL(this.authorizationServerURL,`/${encodeURIComponent(policyId)}`), { method: 'PATCH', headers: { - 'Authorization': `WebID ${encodeURIComponent(webId)}`, 'Content-type': 'application/sparql-update' }, body: body @@ -105,11 +97,9 @@ export class ODRLPolicyService { /** * Function to insert an action rule for each permission in the provided array. They will be inserted in a new policy, via POST and not PATCH. */ - public async insertActionRule(targetId: string, actions: Permission[], assignee: string = ""): Promise { - const webId = getDefaultSession().info.webId! - + public async insertActionRule(targetId: string, actions: Permission[], owner: string, assignee: string = ""): Promise { // Find out if this target already has a policy - const store = (await this.fetchPolicies(webId)); + const store = await this.fetchPolicies(); const ruleIds = store.getQuads(null, ODRL('target'), namedNode(targetId), null).map(quad => quad.subject); const policyIds = new Set(); ruleIds.forEach(ruleId => @@ -128,7 +118,7 @@ export class ODRLPolicyService { for (const action of actions) { - // We need a proper way to create new rules, probably better server side? + // We need a proper way to create new rules, probably better server side? const ruleId = `http://example.org/rule${this.getRandomString(20)}`; // Define the new triples in the rule @@ -140,7 +130,7 @@ export class ODRLPolicyService { // The response contains the full and updated version of the policy, which we cannot return in this interface // If there already exists a policy for this target, patch this rule into it. Otherwise, just post a new one const response = policyIds.size > 0 - ? this.patchPolicy(webId, policyId, ` + ? this.patchPolicy(policyId, ` PREFIX odrl: INSERT { <${policyId}> odrl:permission <${ruleId}> . @@ -148,11 +138,11 @@ INSERT { odrl:target <${targetId}> ; ${actionTriple} ${assigneeTriple} - odrl:assigner <${webId}> . + odrl:assigner <${owner}> . } WHERE {}`) // ! this branch below has no use, as the current version of LOAMA is unable to create new policies on its own, it can only discover the policies already sent. - : this.postPolicy(webId, ` + : this.postPolicy(` @prefix odrl: . <${policyId}> a odrl:Agreement ; odrl:uid <${policyId}> ; @@ -162,23 +152,19 @@ WHERE {}`) odrl:target <${targetId}> ; ${actionTriple} ${assigneeTriple} - odrl:assigner <${webId}> . + odrl:assigner <${owner}> . `) } } /** - * Funcion that searches every owned rule by the logged on client, finds the target + * Funcion that searches every owned rule by the logged on client, finds the target * of an assigner and deletes the actions on it */ public async deleteActionRule(targetId: string, actions: Permission[], assignee: string = ""): Promise { - const session = getDefaultSession(); - const webId = session.info.webId!; - // 1: Fetch the policy contents - const response = await fetch(UMA_URL(this.authorizationServerURL), { + const response = await authenticatedFetch(UMA_URL(this.authorizationServerURL), { headers: { - Authorization: `WebID ${encodeURIComponent(webId)}`, Accept: "text/turtle" } }); @@ -235,11 +221,10 @@ WHERE {}`) // 4: Delete the rule that has the matching target and permission for the matching assignee for (const policyId of policyIds.keys()) { for (const ruleId of policyIds.get(policyId)!) { - const deleteResponse = await fetch( + const deleteResponse = await authenticatedFetch( UMA_URL(this.authorizationServerURL, `/${encodeURIComponent(policyId)}`), { method: "PATCH", headers: { - "Authorization": `WebID ${encodeURIComponent(webId)}`, "Content-type": "application/sparql-update", }, body: ` @@ -261,4 +246,4 @@ DELETE { } } } -} \ No newline at end of file +} diff --git a/controller/src/classes/utils/PolicyInterpreter.ts b/controller/src/classes/utils/PolicyInterpreter.ts index a4bdcc80..b6802a98 100644 --- a/controller/src/classes/utils/PolicyInterpreter.ts +++ b/controller/src/classes/utils/PolicyInterpreter.ts @@ -1,3 +1,4 @@ +import { getLoggedInIdentifier } from './Authentication'; import { Constraint, ISpecificTargetInfo, Policy, Rule } from "../../types/modules"; import { DataFactory, Store, Writer } from "n3"; import { ODRL } from "./PolicyParser"; @@ -11,7 +12,7 @@ export class PolicyInterpreter { /** * Extract the quads of one subject, and recursively add whatever their object is referring to * @param store store to extract subject from - * @param subjectIRI + * @param subjectIRI * @param existing IDs that have already been added to the store * @returns detailed store of the original subject and all of their children */ @@ -32,7 +33,7 @@ export class PolicyInterpreter { } /** - * transforms N3 data-store object to Policy object + * transforms N3 data-store object to Policy object * @param store the fetched policies * @param resourceUrl if given, only rules targeting this resource are included */ @@ -66,7 +67,7 @@ export class PolicyInterpreter { const constraintNodes = store.getObjects(permNamedNode, ODRL("constraint"), null); const actions = actionNodes.map(node => node.value); - const targets = targetNodes.map(node => node.value); + const targets = targetNodes.map(node => node.value); const assignees = assigneeNodes.map(node => node.value); const assigners = assignerNodes.map(node => node.value); const constraintIds = constraintNodes.map(node => node.value); @@ -79,13 +80,13 @@ export class PolicyInterpreter { resourceIdentifier: targets[0], constraint: [] } - + constraintIds.forEach(constrId =>{ const constrNamedNode = namedNode(constrId); const leftOperandNodes = store.getObjects(constrNamedNode, ODRL("leftOperand"), null); const operatorNodes = store.getObjects(constrNamedNode, ODRL("operator"), null); const rightOperandNodes = store.getObjects(constrNamedNode, ODRL("rightOperand"), null); - + const leftOperand = leftOperandNodes[0]?.value; const operator = operatorNodes[0]?.value; const rightOperand = rightOperandNodes.map(node => node.value); @@ -108,11 +109,10 @@ export class PolicyInterpreter { /** * Transform policy object to a turtle-string - * @param webId - * @param policy - * @returns + * @param policy + * @returns */ - public policyToTurtle(webId: string, policy: Policy): string { + public policyToTurtle(policy: Policy): string { const ODRL = 'http://www.w3.org/ns/odrl/2/'; const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; @@ -145,7 +145,7 @@ export class PolicyInterpreter { writer.addQuad(ruleNode, namedNode(`${ODRL}assignee`), namedNode(rule.subjectId)); } - writer.addQuad(ruleNode, namedNode(`${ODRL}assigner`), namedNode(webId)); + writer.addQuad(ruleNode, namedNode(`${ODRL}assigner`), namedNode(getLoggedInIdentifier())); if (rule.action && rule.action.length > 0) { for (const act of rule.action) { @@ -215,4 +215,4 @@ export class PolicyInterpreter { return turtleText; } -} \ No newline at end of file +} diff --git a/controller/src/index.ts b/controller/src/index.ts index 6588da3c..db365544 100644 --- a/controller/src/index.ts +++ b/controller/src/index.ts @@ -3,3 +3,4 @@ export * from "./types/modules"; export * from "./types/subjects"; export * from "./controllers"; export * from './classes'; +export * from './classes/utils/Authentication'; diff --git a/controller/src/types/modules.ts b/controller/src/types/modules.ts index bd95ca7f..a3c5bea8 100644 --- a/controller/src/types/modules.ts +++ b/controller/src/types/modules.ts @@ -14,10 +14,10 @@ export interface IController>(subject: T[K]): string; getOrCreateIndex(): Promise; - + updatePolicy(updates: RuleUpdate[]): Promise; getResourcePolicies(resourceUrl: string): Promise; - + /** * Enables a the permissions for an existing subject * @throws Error if the item does not exist for the given subject @@ -88,7 +88,7 @@ export interface ISubjectResolver> { export interface IPermissionManager>> { // Does not update the index file - createPermissions>(resource: string, subject: T[K], permissions: Permission[]): Promise + createPermissions>(resource: string, subject: T[K], permissions: Permission[], owner: string): Promise // Does not update the index file editPermissions>(resource: string, item: IndexItem, subject: T[K], permissions: Permission[]): Promise deletePermissions>(resource: string, subject: T[K], permissions: Permission[]): Promise diff --git a/loama/src/components/LoginForm.vue b/loama/src/components/LoginForm.vue index d69c140d..f88a41a4 100644 --- a/loama/src/components/LoginForm.vue +++ b/loama/src/components/LoginForm.vue @@ -32,6 +32,7 @@ import { ref } from 'vue'; import { store } from 'loama-app' import LoButton from './LoButton.vue'; import { PhArrowRight, PhLink, PhQuestion } from '@phosphor-icons/vue'; +import { getOrRegisterDynamicClient } from '@/lib/oidcDynamicRegistration'; defineProps<{ title: string, subtitle?: string }>(); @@ -46,26 +47,53 @@ const defaultSolidPodUrl = import.meta.env.VITE_DEFAULT_IDP; const showWarning = ref(false); const isLoading = ref(false); -const login = () => { +const login = async () => { isLoading.value = true; + showWarning.value = false; + const issuer = solidPodUrl.value.trim() || defaultSolidPodUrl; + const configuredClientId = import.meta.env.VITE_OIDC_CLIENT_ID?.trim(); const searchParams = new URLSearchParams(location.search) const nextPath = searchParams.get("next") ?? "home" - store.session.login({ - oidcIssuer: issuer, - redirectUrl: new URL(`${import.meta.env.BASE_URL}${nextPath}/`, window.location.href).toString(), - clientName: 'LOAMA', - }) - .then(() => { - showWarning.value = false; - isLoading.value = false; - }) - .catch(() => { - showWarning.value = true; - isLoading.value = false; + const redirectUrl = new URL(`${import.meta.env.BASE_URL}${nextPath}/`, window.location.href).toString(); + const postLogoutRedirectUrl = new URL(`${import.meta.env.BASE_URL}`, window.location.href).toString(); + + try { + let clientId: string; + try { + clientId = await getOrRegisterDynamicClient({ + issuer, + redirectUri: redirectUrl, + postLogoutRedirectUri: postLogoutRedirectUrl, + clientName: 'LOAMA', + }); + } catch (registrationError) { + if (!configuredClientId) { + throw registrationError; + } + clientId = configuredClientId; + } + + store.configureOidc({ + authority: issuer, + clientId, + redirectUrl, + postLogoutRedirectUrl, + scope: 'openid profile webid', + }); + + // Preserve the old behavior of showing the chosen IdP in the UI state. + store.setUsedPod(issuer); + + await store.getOidcManager().signinRedirect({ + state: { nextPath }, }); + } catch { + showWarning.value = true; + isLoading.value = false; + } }; const noPod = () => { diff --git a/loama/src/components/header/HeaderBase.vue b/loama/src/components/header/HeaderBase.vue index 0faa61f1..778f8976 100644 --- a/loama/src/components/header/HeaderBase.vue +++ b/loama/src/components/header/HeaderBase.vue @@ -24,10 +24,7 @@ import LoButton from '../LoButton.vue' import HeaderContextMenu from './HeaderContextMenu.vue' import { PhShareFat } from '@phosphor-icons/vue'; -import { getProfileInfo } from "loama-common"; -import { store } from 'loama-app' - -const pfpSrc = (await getProfileInfo(store.session, store.usedPod.replace(/\/$/, ''))).img; +const pfpSrc = `${import.meta.env.BASE_URL}profile.svg`; const isContextMenuHidden = ref(true) diff --git a/loama/src/components/header/HeaderContextMenu.vue b/loama/src/components/header/HeaderContextMenu.vue index 78e44ae2..3358f118 100644 --- a/loama/src/components/header/HeaderContextMenu.vue +++ b/loama/src/components/header/HeaderContextMenu.vue @@ -39,11 +39,14 @@ import router from '@/router'; import { store } from 'loama-app'; import { PhSignOut } from '@phosphor-icons/vue'; -import { listPodUrls } from 'loama-common'; import { useControllerStore } from '@/stores/useControllerStore'; import { computed, ref } from 'vue'; -const pods = await listPodUrls(store.session); +const pods = computed(() => { + if (store.usedPod) return [store.usedPod]; + if (store.oidcSettings?.authority) return [store.oidcSettings.authority]; + return []; +}); const controllerStore = useControllerStore(); const types = computed(() => Array.from(controllerStore.types)); @@ -59,8 +62,11 @@ function updateController() { async function logout() { if (controllerStore.current) controllerStore.current.unsetPodUrl(""); - store.session.logout(); - router.push('/'); + try { + await store.getOidcManager().signoutRedirect(); + } catch { + router.push('/'); + } } diff --git a/loama/src/lib/oidcDynamicRegistration.ts b/loama/src/lib/oidcDynamicRegistration.ts new file mode 100644 index 00000000..f35c9e11 --- /dev/null +++ b/loama/src/lib/oidcDynamicRegistration.ts @@ -0,0 +1,74 @@ +export interface DynamicRegistrationOptions { + issuer: string; + redirectUri: string; + postLogoutRedirectUri: string; + clientName: string; +} + +interface OidcMetadata { + registration_endpoint?: string; +} + +interface RegistrationResponse { + client_id?: string; +} + +function cacheKey(issuer: string, redirectUri: string, postLogoutRedirectUri: string): string { + return `loama.oidc.dynamic-client.${issuer}|${redirectUri}|${postLogoutRedirectUri}`; +} + +function loadCachedClientId(issuer: string, redirectUri: string, postLogoutRedirectUri: string): string | undefined { + const key = cacheKey(issuer, redirectUri, postLogoutRedirectUri); + return window.localStorage.getItem(key) ?? undefined; +} + +function saveCachedClientId(issuer: string, redirectUri: string, postLogoutRedirectUri: string, clientId: string): void { + const key = cacheKey(issuer, redirectUri, postLogoutRedirectUri); + window.localStorage.setItem(key, clientId); +} + +export async function getOrRegisterDynamicClient(options: DynamicRegistrationOptions): Promise { + const { issuer, redirectUri, postLogoutRedirectUri, clientName } = options; + + const cached = loadCachedClientId(issuer, redirectUri, postLogoutRedirectUri); + if (cached) return cached; + + const metadataResponse = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`); + if (!metadataResponse.ok) { + throw new Error(`Failed to fetch OIDC metadata (${metadataResponse.status}).`); + } + + const metadata = (await metadataResponse.json()) as OidcMetadata; + if (!metadata.registration_endpoint) { + throw new Error('OIDC provider has no dynamic registration endpoint. Configure a static client ID instead.'); + } + + const registrationResponse = await fetch(metadata.registration_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_name: clientName, + application_type: 'web', + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + redirect_uris: [redirectUri], + post_logout_redirect_uris: [postLogoutRedirectUri], + }), + }); + + if (!registrationResponse.ok) { + const errorBody = await registrationResponse.text(); + throw new Error(`Dynamic registration failed (${registrationResponse.status}): ${errorBody}`); + } + + const registration = (await registrationResponse.json()) as RegistrationResponse; + if (!registration.client_id) { + throw new Error('Dynamic registration did not return a client_id.'); + } + + saveCachedClientId(issuer, redirectUri, postLogoutRedirectUri, registration.client_id); + return registration.client_id; +} diff --git a/loama/src/router/index.ts b/loama/src/router/index.ts index 61db4211..9cfe0143 100644 --- a/loama/src/router/index.ts +++ b/loama/src/router/index.ts @@ -2,11 +2,26 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue' import LoginView from '@/views/LoginView.vue' import { store } from 'loama-app' -import { listPodUrls } from 'loama-common' import HeaderLayout from '@/components/layouts/HeaderLayout.vue' import { useControllerStore } from '@/stores/useControllerStore' import AccessRequest from '@/components/access-requests/AccessRequest.vue' import AccessGrant from '@/components/access-grants/AccessGrant.vue' +import { clearAuthenticationContext, setAuthenticationContext } from 'loama-controller' + +function resolvePodUrl(): string { + if (store.usedPod) return store.usedPod; + return store.oidcSettings?.authority ?? ''; +} + +async function applyAuthenticatedUserState(controllerStore: ReturnType, accessToken: string, identifier: string): Promise { + setAuthenticationContext({ accessToken, identifier }); + + const podUrl = resolvePodUrl(); + if (podUrl) { + store.setUsedPod(podUrl); + await controllerStore.current.setPodUrl(podUrl); + } +} const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -44,19 +59,46 @@ router.beforeEach(async (to) => { // don't move this call to outside this function, as this function is only ran after app.use pinia has had the change to run const controllerStore = useControllerStore(); - if (!store.session.info.isLoggedIn) { - await store.session.handleIncomingRedirect({ - restorePreviousSession: true, - }) - if (store.session.info.isLoggedIn) { - // Default to the first pod - const currentPodUrl = (await listPodUrls(store.session))[0] - await controllerStore.current.setPodUrl(currentPodUrl); - store.setUsedPod(currentPodUrl) + try { + const manager = store.getOidcManager(); + + // Handle callback on the original redirect target route. + if (typeof to.query.code === 'string' && typeof to.query.state === 'string') { + const callbackUser = await manager.signinRedirectCallback(window.location.href); + const identifier = String(callbackUser.profile.webid ?? callbackUser.profile.sub ?? ''); + + if (!callbackUser.access_token || !identifier) { + throw new Error('OIDC callback did not return required user claims.'); + } + + await applyAuthenticatedUserState(controllerStore, callbackUser.access_token, identifier); + return { + path: to.path, + query: {}, + hash: to.hash, + }; + } + + const user = await manager.getUser(); + const isLoggedIn = !!user && !user.expired; + + if (!isLoggedIn) { + clearAuthenticationContext(); + if (to.name !== 'login') { + return { name: 'login', query: { next: to.name?.toString() } }; + } + return; } - if (!store.session.info.isLoggedIn && to.name !== 'login') { - return { name: 'login', query: { "next": to.name?.toString() } } + + const identifier = String(user.profile.webid ?? user.profile.sub ?? ''); + if (!identifier) { + throw new Error('OIDC user has no usable identifier claim.'); } + + await applyAuthenticatedUserState(controllerStore, user.access_token, identifier); + } catch { + clearAuthenticationContext(); + if (to.name !== 'login') return { name: 'login', query: { next: to.name?.toString() } }; } }) diff --git a/solid-app-lib/package.json b/solid-app-lib/package.json index 405af0b0..5f4b2558 100644 --- a/solid-app-lib/package.json +++ b/solid-app-lib/package.json @@ -24,8 +24,8 @@ }, "dependencies": { "@inrupt/solid-client": "^2.0.1", - "@inrupt/solid-client-authn-browser": "^2.2.4", - "loama-common": "^1.0.0" + "loama-common": "^1.0.0", + "oidc-client-ts": "^3.5.0" }, "devDependencies": { "typescript": "^5.5.4" diff --git a/solid-app-lib/src/store.ts b/solid-app-lib/src/store.ts index d316f58f..379c4c81 100644 --- a/solid-app-lib/src/store.ts +++ b/solid-app-lib/src/store.ts @@ -1,10 +1,92 @@ -import { getDefaultSession } from '@inrupt/solid-client-authn-browser' import { reactive, markRaw } from 'vue' +import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts' -export const store = reactive({ - session: markRaw(getDefaultSession()), +const OIDC_SETTINGS_KEY = 'loama.oidc.settings'; + +export interface OidcLoginSettings { + authority: string; + clientId: string; + redirectUrl: string; + postLogoutRedirectUrl: string; + scope?: string; +} + +function toUserManagerSettings(settings: OidcLoginSettings): UserManagerSettings { + return { + authority: settings.authority, + client_id: settings.clientId, + redirect_uri: settings.redirectUrl, + post_logout_redirect_uri: settings.postLogoutRedirectUrl, + response_type: 'code', + scope: settings.scope ?? 'openid profile', + userStore: new WebStorageStateStore({ store: window.localStorage }), + }; +} + +function saveOidcSettings(settings: OidcLoginSettings): void { + window.localStorage.setItem(OIDC_SETTINGS_KEY, JSON.stringify(settings)); +} + +function loadOidcSettings(): OidcLoginSettings | undefined { + const raw = window.localStorage.getItem(OIDC_SETTINGS_KEY); + if (!raw) return; + + try { + const parsed = JSON.parse(raw) as OidcLoginSettings; + if (!parsed.authority || !parsed.clientId || !parsed.redirectUrl || !parsed.postLogoutRedirectUrl) { + return; + } + return parsed; + } catch { + return; + } +} + +function createManager(settings?: OidcLoginSettings): UserManager | undefined { + if (!settings) return; + return new UserManager(toUserManagerSettings(settings)); +} + +function createRawManager(settings?: OidcLoginSettings): UserManager | undefined { + const manager = createManager(settings); + return manager ? markRaw(manager) : undefined; +} + +export interface OidcStore { + oidcSettings?: OidcLoginSettings; + oidcManager?: UserManager; + usedPod: string; + setUsedPod(url: string): void; + configureOidc(settings: OidcLoginSettings): void; + getOidcManager(): UserManager; +} + +const initialOidcSettings = loadOidcSettings(); + +const storeState = reactive({ + oidcSettings: initialOidcSettings, + oidcManager: createRawManager(initialOidcSettings), usedPod: '', setUsedPod(url: string) { - this.usedPod = url + this.usedPod = url; + }, + configureOidc(settings: OidcLoginSettings) { + this.oidcSettings = settings; + saveOidcSettings(settings); + this.oidcManager = createRawManager(settings); + }, + getOidcManager(): UserManager { + if (!this.oidcManager) { + if (!this.oidcSettings) { + throw new Error('OIDC is not configured. Start login first.'); + } + this.oidcManager = createRawManager(this.oidcSettings); + } + if (!this.oidcManager) { + throw new Error('OIDC manager could not be created.'); + } + return this.oidcManager; } -}) +}) as OidcStore; + +export const store = storeState; diff --git a/yarn.lock b/yarn.lock index b861f1ad..690d76f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8333,6 +8333,13 @@ __metadata: languageName: node linkType: hard +"jwt-decode@npm:^4.0.0": + version: 4.0.0 + resolution: "jwt-decode@npm:4.0.0" + checksum: 10c0/de75bbf89220746c388cf6a7b71e56080437b77d2edb29bae1c2155048b02c6b8c59a3e5e8d6ccdfd54f0b8bda25226e491a4f1b55ac5f8da04cfbadec4e546c + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -8380,6 +8387,7 @@ __metadata: "@inrupt/solid-client": "npm:^2.0.1" "@inrupt/solid-client-authn-browser": "npm:^2.2.4" loama-common: "npm:^1.0.0" + oidc-client-ts: "npm:^3.5.0" typescript: "npm:^5.5.4" languageName: unknown linkType: soft @@ -8959,6 +8967,15 @@ __metadata: languageName: node linkType: hard +"oidc-client-ts@npm:^3.5.0": + version: 3.5.0 + resolution: "oidc-client-ts@npm:3.5.0" + dependencies: + jwt-decode: "npm:^4.0.0" + checksum: 10c0/93cbb62adbfeee4bf705199a94e5790217f211b85dd988827e555481d4e605a5263292b740f29765ac01528b99486f57bbd96679ffca53d7ef76d8f7f7c9f979 + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0"