-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement experimental support for ActivityPods
- Loading branch information
1 parent
adb0d2a
commit 9555ba8
Showing
15 changed files
with
3,978 additions
and
3,387 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
lts/hydrogen | ||
v20.15.1 |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
|
||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.