Skip to content

Commit

Permalink
Implement experimental support for ActivityPods
Browse files Browse the repository at this point in the history
  • Loading branch information
NoelDeMartin committed Dec 20, 2024
1 parent adb0d2a commit 9555ba8
Show file tree
Hide file tree
Showing 15 changed files with 3,978 additions and 3,387 deletions.
1 change: 1 addition & 0 deletions .env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_ACTIVITYPODS_CLIENT_ID=https://rem.noeldemartin.com/applications/ramen-dev.jsonld
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/hydrogen
v20.15.1
6,937 changes: 3,570 additions & 3,367 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@
"test:serve-pod": "community-solid-server -l warn"
},
"dependencies": {
"@aerogel/core": "next",
"@aerogel/plugin-i18n": "next",
"@aerogel/plugin-solid": "next",
"@aerogel/plugin-soukai": "next",
"@aerogel/core": "0.0.0-next.f9394854509d71d644498ac087706a2f8f8eea1c",
"@aerogel/plugin-i18n": "0.0.0-next.f9394854509d71d644498ac087706a2f8f8eea1c",
"@aerogel/plugin-solid": "0.0.1-next.f9394854509d71d644498ac087706a2f8f8eea1c",
"@aerogel/plugin-soukai": "0.0.0-next.f9394854509d71d644498ac087706a2f8f8eea1c",
"@intlify/unplugin-vue-i18n": "^0.12.2",
"@noeldemartin/utils": "next",
"@noeldemartin/utils": "0.5.1-next.8877300615e6d56d7b5dfe508524589287835f23",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"soukai": "next",
"soukai-solid": "0.5.2-next.b6340345e228903e404a02c1ed9be8fc95162e8f",
"oauth4webapi": "^2.17.0",
"soukai": "0.5.2-next.26947ce0d23c9cb22aedb591ad36dde357b9906f",
"soukai-solid": "0.5.2-next.c8e51620dd240521cb1a339487049e5573baaad3",
"tailwindcss": "^3.3.2",
"vue": "^3.3.0",
"vue-i18n": "9.3.0-beta.19"
Expand Down
173 changes: 173 additions & 0 deletions src/auth/ActivityPodsAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import AerogelSolid from 'virtual:aerogel-solid';

import { after, deleteLocationQueryParameters, fail, required } from '@noeldemartin/utils';
import { Authenticator, Solid } from '@aerogel/plugin-solid';
import type { AuthorizationServer, Client, OpenIDTokenEndpointResponse } from 'oauth4webapi';
import type { AuthSession } from '@aerogel/plugin-solid';
import type { Reactive } from 'vue';
import type { SolidUserProfile } from '@noeldemartin/solid-utils';

import persistent from '@/lib/persistent';

interface Data {
token?: string;
webId?: string;
loginUrl?: string;
codeVerifier?: string;
}

export default class ActivityPodsAuthenticator extends Authenticator {

private store: Reactive<Data>;

constructor() {
super();

this.store = persistent('activitypods-authenticator', {});
}

public async login(loginUrl: string, user?: SolidUserProfile | null): Promise<AuthSession> {
if (!AerogelSolid.clientID) {
throw new Error('ActivityPods: Can\'t login in because ClientID document is missing');
}

const oauth = await import('oauth4webapi');
const codeVerifier = oauth.generateRandomCodeVerifier();
const authorizationServer = await this.getAuthorizationServer(loginUrl);
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
const authorizationUrl = new URL(authorizationServer.authorization_endpoint as string);

authorizationUrl.searchParams.set('client_id', this.getOAuthClient().client_id);
authorizationUrl.searchParams.set('redirect_uri', AerogelSolid.clientID.redirect_uris[0]);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
authorizationUrl.searchParams.set('scope', 'openid webid offline_access');
authorizationUrl.searchParams.set('is_signup', 'false');

this.store.webId = user?.webId;
this.store.loginUrl = loginUrl;
this.store.codeVerifier = codeVerifier;

window.location.href = authorizationUrl.href;

// Browser should redirect, so just make it wait for a while.
await after({ seconds: 60 });

throw new Error('Browser should have redirected, but it didn\'t');
}

public async logout(): Promise<void> {
delete this.store.token;
delete this.store.webId;
delete this.store.loginUrl;
delete this.store.codeVerifier;

await this.endSession();
}

protected async restoreSession(): Promise<void> {
const { searchParams } = new URL(window.location.href);

if (searchParams.has('code')) {
await this.processCallbackUrl(searchParams.get('iss') ?? fail('\'iss\' missing from redirect url'));
}

if (!this.store.token) {
return;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.initAuthenticatedFetch((url: RequestInfo, options?: any) => {
if (typeof url === 'string') {
options = {
url,
...options,
};
}

options.headers ??= {};
options.headers['Authorization'] = `Bearer ${this.store.token}`;

return fetch(url, options);
});

await this.startSession({
user: await Solid.requireUserProfile(required(this.store.webId)),
loginUrl: required(this.store.loginUrl),
});
}

protected async processCallbackUrl(issuerUrl: string): Promise<void> {
const authorizationServer = await this.getAuthorizationServer(issuerUrl);
const callbackParameters = await this.getCallbackParameters(authorizationServer);
const { id_token } = await this.requestToken(authorizationServer, callbackParameters);

this.store.token = id_token;

delete this.store.codeVerifier;

await deleteLocationQueryParameters(['state', 'code', 'iss', 'error', 'error_description']);
}

protected async getAuthorizationServer(issuerUrl: string): Promise<AuthorizationServer> {
const url = new URL(issuerUrl);
const oauth = await import('oauth4webapi');
const authorizationServer = await oauth
.discoveryRequest(url)
.then((response) => oauth.processDiscoveryResponse(url, response));

return authorizationServer;
}

protected async getCallbackParameters(authorizationServer: AuthorizationServer): Promise<URLSearchParams> {
const oauth = await import('oauth4webapi');
const searchParams = oauth.validateAuthResponse(
authorizationServer,
this.getOAuthClient(),
new URL(window.location.href),
oauth.expectNoState,
);

if (oauth.isOAuth2Error(searchParams)) {
throw new Error(`OAuth error: ${searchParams.error} (${searchParams.error_description})`);
}

return searchParams;
}

protected async requestToken(
authorizationServer: AuthorizationServer,
callbackParameters: URLSearchParams,
): Promise<OpenIDTokenEndpointResponse> {
const oauth = await import('oauth4webapi');

const authorizationCodeResponse = await oauth.authorizationCodeGrantRequest(
authorizationServer,
this.getOAuthClient(),
callbackParameters,
required(AerogelSolid.clientID).redirect_uris[0],
required(this.store.codeVerifier),
);

const tokenResponse = await oauth.processAuthorizationCodeOpenIDResponse(
authorizationServer,
this.getOAuthClient(),
authorizationCodeResponse,
);

if (oauth.isOAuth2Error(tokenResponse)) {
throw new Error(`OAuth error: ${tokenResponse.error} (${tokenResponse.error_description})`);
}

return tokenResponse;
}

protected getOAuthClient(): Client {
return {
client_id: import.meta.env.VITE_ACTIVITYPODS_CLIENT_ID ?? fail('ActivityPods clientId missing from .env'),
token_endpoint_auth_method: 'none',
};
}

}
80 changes: 80 additions & 0 deletions src/auth/RamenAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Authenticator, getAuthenticator } from '@aerogel/plugin-solid';
import { fail } from '@noeldemartin/utils';
import type { AuthSession, AuthenticatorName } from '@aerogel/plugin-solid';
import type { Fetch } from 'soukai-solid';
import type { Reactive } from 'vue';
import type { SolidUserProfile } from '@noeldemartin/solid-utils';

import persistent from '@/lib/persistent';

interface Data {
authenticator?: AuthenticatorName;
}

export default class RamenAuthenticator extends Authenticator {

private authenticator: Authenticator | null = null;
private store: Reactive<Data>;

constructor() {
super();

this.store = persistent('ramen-authenticator', {});
}

public async login(loginUrl: string, user?: SolidUserProfile | null): Promise<AuthSession> {
this.authenticator = this.resolveAuthenticator(user);

this.store.authenticator = this.authenticator.name;

await this.bootAuthenticator(this.authenticator);

return this.authenticator.login(loginUrl, user);
}

public async logout(): Promise<void> {
const authenticator = this.authenticator;

this.authenticator = null;

delete this.store.authenticator;

return authenticator?.logout();
}

public getAuthenticatedFetch(): Fetch | null {
return this.authenticator?.getAuthenticatedFetch() ?? null;
}

public requireAuthenticatedFetch(): Fetch {
return this.authenticator?.requireAuthenticatedFetch() ?? fail('Failed getting ramen authenticator');
}

protected async restoreSession(): Promise<void> {
if (this.store.authenticator) {
this.authenticator = getAuthenticator(this.store.authenticator);

await this.bootAuthenticator(this.authenticator);
}
}

protected resolveAuthenticator(user?: SolidUserProfile | null): Authenticator {
if (user?.usesActivityPods) {
return getAuthenticator('activity-pods');
}

return getAuthenticator('inrupt');
}

protected async bootAuthenticator(authenticator: Authenticator): Promise<void> {
authenticator.addListener({
onSessionStarted: (...args) => this.notifyListeners('onSessionStarted', ...args),
onSessionFailed: (...args) => this.notifyListeners('onSessionFailed', ...args),
onSessionEnded: (...args) => this.notifyListeners('onSessionEnded', ...args),
onAuthenticatedFetchReady: (...args) => this.notifyListeners('onAuthenticatedFetchReady', ...args),
});

await authenticator.boot();
}

}
32 changes: 32 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Options } from '@aerogel/plugin-solid';

import ActivityPodsAuthenticator from '@/auth/ActivityPodsAuthenticator';
import RamenAuthenticator from '@/auth/RamenAuthenticator';

export const authConfig: Options = {
defaultAuthenticator: 'ramen',
authenticators: {
'activity-pods': new ActivityPodsAuthenticator(),
'ramen': new RamenAuthenticator(),
},
onUserProfileLoaded(profile, store) {
profile.usesActivityPods = !!store.statement(
profile.webId,
'http://www.w3.org/1999/02/22-rdf-syntax-ns#type',
'https://www.w3.org/ns/activitystreams#Person',
);
},
};

declare module '@aerogel/plugin-solid' {
interface Authenticators {
'activity-pods': ActivityPodsAuthenticator;
ramen: RamenAuthenticator;
}
}

declare module '@noeldemartin/solid-utils' {
interface SolidUserProfile {
usesActivityPods?: boolean;
}
}
4 changes: 2 additions & 2 deletions src/components/AppLoginError.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@
import { computed } from 'vue';
import { Solid } from '@aerogel/plugin-solid';
import { Errors, translate } from '@aerogel/core';
import { getErrorMessage, translate } from '@aerogel/core';
const errorMessage = computed(() => {
if (!Solid.error) {
return;
}
const message = Errors.getErrorMessage(Solid.error);
const message = getErrorMessage(Solid.error);
return translate('login.errorInfo', { message });
});
Expand Down
4 changes: 2 additions & 2 deletions src/components/lib/InlineSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
v-for="(option, index) in $select?.options"
v-slot="{ active, selected }: IAGHeadlessSelectOptionSlotProps"
:key="index"
:value="option.value"
:value="option"
as="template"
>
<li
Expand All @@ -30,7 +30,7 @@
'font-medium': selected,
}"
>
{{ option.text }}
{{ option }}
</li>
</AGHeadlessSelectOption>
</AGHeadlessSelectOptions>
Expand Down
33 changes: 33 additions & 0 deletions src/lib/persistent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';

import persistent from './persistent';
import { Storage } from '@noeldemartin/utils';
import { nextTick } from 'vue';

describe('persistent helper', () => {

it('serializes to localStorage', async () => {
// Arrange
const store = persistent<{ foo?: string }>('foobar', {});

// Act
store.foo = 'bar';

await nextTick();

// Assert
expect(Storage.get('foobar')).toEqual({ foo: 'bar' });
});

it('reads from localStorage', async () => {
// Arrange
Storage.set('foobar', { foo: 'bar' });

// Act
const store = persistent<{ foo?: string }>('foobar', {});

// Assert
expect(store.foo).toEqual('bar');
});

});
11 changes: 11 additions & 0 deletions src/lib/persistent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { reactive, toRaw, watch } from 'vue';
import { Storage } from '@noeldemartin/utils';
import type { Reactive } from 'vue';

export default function persistent<T extends object>(name: string, defaults: T): Reactive<T> {
const store = reactive<T>(Storage.get<T>(name) ?? defaults);

watch(store, () => Storage.set(name, toRaw(store)));

return store;
}
Loading

0 comments on commit 9555ba8

Please sign in to comment.