diff --git a/src/frontend/packages/auth-provider/package.json b/src/frontend/packages/auth-provider/package.json index a8717ea84..d5e5a59fc 100644 --- a/src/frontend/packages/auth-provider/package.json +++ b/src/frontend/packages/auth-provider/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@semapps/semantic-data-provider": "1.2.0", + "@inrupt/solid-client-authn-browser": "^3.1.1", "classnames": "^2.3.2", "jwt-decode": "^3.0.0", "lodash": "^4.17.21", diff --git a/src/frontend/packages/auth-provider/src/solidAuthProvider.ts b/src/frontend/packages/auth-provider/src/solidAuthProvider.ts new file mode 100644 index 000000000..3b0855aee --- /dev/null +++ b/src/frontend/packages/auth-provider/src/solidAuthProvider.ts @@ -0,0 +1,200 @@ +import jwtDecode from 'jwt-decode'; +import * as oauth from 'oauth4webapi'; +import { SemanticDataProvider } from '@semapps/semantic-data-provider'; +import { AuthProvider, UserIdentity } from 'react-admin'; + +interface SolidAuthProviderSettings { + dataProvider: SemanticDataProvider; + allowAnonymous?: boolean; + checkUser?: (userData: any) => boolean; + clientId: string; +} + +const solidAuthProvider = ({ + dataProvider, + allowAnonymous = true, + checkUser, + clientId +}: SolidAuthProviderSettings): AuthProvider => ({ + login: async (params: any) => { + let { webId, issuer, redirect = '/', isSignup = false } = params; + + if (webId && !issuer) { + // Find issuer from webId + const { json: userData } = await dataProvider.fetch(webId); + if (!userData) throw new Error('auth.message.unable_to_fetch_user_data'); + if (!userData['solid:oidcIssuer']) throw new Error('auth.message.no_associated_oidc_issuer'); + issuer = userData?.['solid:oidcIssuer']; + } + + const as = await oauth + .discoveryRequest(new URL(issuer)) + .then(response => oauth.processDiscoveryResponse(new URL(issuer), response)) + .catch(() => { + throw new Error('auth.message.unreachable_auth_server'); + }); + + const codeVerifier = oauth.generateRandomCodeVerifier(); + const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier); + const codeChallengeMethod = 'S256'; + + // Save to use on handleCallback method + localStorage.setItem('code_verifier', codeVerifier); + localStorage.setItem('redirect', redirect); + + const authorizationUrl = new URL(as.authorization_endpoint!); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', clientId); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod); + authorizationUrl.searchParams.set('redirect_uri', `${window.location.origin}/auth-callback`); + authorizationUrl.searchParams.set('scope', 'openid webid offline_access'); + authorizationUrl.searchParams.set('is_signup', isSignup); + + // @ts-expect-error TS(2322): Type 'URL' is not assignable to type '(string | Lo... Remove this comment to see the full error message + window.location = authorizationUrl; + }, + handleCallback: async () => { + const { searchParams } = new URL(window.location.href); + + // @ts-expect-error TS(2345): Argument of type 'string | null' is not assignable... Remove this comment to see the full error message + const issuer = new URL(searchParams.get('iss')); + const as = await oauth.discoveryRequest(issuer).then(response => oauth.processDiscoveryResponse(issuer, response)); + + const client = { + client_id: clientId, + token_endpoint_auth_method: 'none' // We don't have a client secret + } as oauth.Client; + + const currentUrl = new URL(window.location.href); + const params = oauth.validateAuthResponse(as, client, currentUrl, oauth.expectNoState); + if (oauth.isOAuth2Error(params)) { + throw new Error(`OAuth error: ${params.error} (${params.error_description})`); + } + + // Retrieve data set during login + const codeVerifier = localStorage.getItem('code_verifier')!; + const redirect = localStorage.getItem('redirect'); + + const response = await oauth.authorizationCodeGrantRequest( + as, + client, + params, + `${window.location.origin}/auth-callback`, + codeVerifier + ); + + const result = await oauth.processAuthorizationCodeOpenIDResponse(as, client, response); + if (oauth.isOAuth2Error(result)) { + // @ts-expect-error + throw new Error(`OAuth error: ${params.error} (${params.error_description})`); + } + + // Until DPoP is implemented, use the ID token to log into local Pod + // And the proxy endpoint to log into remote Pods + localStorage.setItem('token', result.id_token); + + // Remove we don't need it anymore + localStorage.removeItem('code_verifier'); + localStorage.removeItem('redirect'); + + // Reload to ensure the dataServer config is reset + window.location.href = redirect || '/'; + }, + signup: async (params: any) => { + // Not implemented ? + }, + logout: async (params: any) => { + const { redirectUrl } = params || {}; + + const token = localStorage.getItem('token'); + if (token) { + const { webid: webId }: any = jwtDecode(token); // Not webId !! + + // Delete token but also any other value in local storage + localStorage.clear(); + + if (redirectUrl) { + return redirectUrl; + } else { + // We don't need the token to fetch the WebID since it is public + const { json: userData } = await dataProvider.fetch(webId); + + // Redirect to the Pod provider + return userData?.['solid:oidcIssuer'] || new URL(webId).origin; + } + } else { + return redirectUrl; + } + }, + checkAuth: async () => { + const token = localStorage.getItem('token'); + if (!token && !allowAnonymous) throw new Error(); + }, + checkUser: (userData: any) => { + if (checkUser) { + return checkUser(userData); + } + return true; + }, + checkError: (error: any) => { + // We want to disconnect only with INVALID_TOKEN errors + if (error.status === 401 && error.body && error.body.type === 'INVALID_TOKEN') { + localStorage.removeItem('token'); + return Promise.reject(); + } else { + // Other error code (404, 500, etc): no need to log out + return Promise.resolve(); + } + }, + getIdentity: async () => { + const token = localStorage.getItem('token'); + if (token) { + const payload = jwtDecode(token); + + const webId = payload.webid; // Not webId !! + + if (!webId) { + // If webId is not set, it is probably because we have ActivityPods v1 tokens and we need to disconnect + localStorage.removeItem('token'); + window.location.href = '/login'; + throw new Error('No webId found on provided token !'); + } + + const { json: webIdData } = await dataProvider.fetch(webId); + let profileData = {}; + + if (webIdData.url) { + try { + const { status, json } = await dataProvider.fetch(webIdData.url); + if (status === 200) profileData = json; + } catch (e) { + // Could not fetch profile. Continue... + console.error(e); + } + } + + return { + id: webId, + fullName: + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + profileData['vcard:given-name'] || + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + profileData['pair:label'] || + webIdData['foaf:name'] || + webIdData['pair:label'], + avatar: + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + profileData['vcard:photo'] || + webIdData.image?.url || + webIdData.image || + webIdData.icon?.url || + webIdData.icon, + profileData, + webIdData + } as UserIdentity; + } + } +}); + +export default solidAuthProvider; diff --git a/src/frontend/packages/auth-provider/src/solidAuthProvider2.ts b/src/frontend/packages/auth-provider/src/solidAuthProvider2.ts new file mode 100644 index 000000000..5928912f5 --- /dev/null +++ b/src/frontend/packages/auth-provider/src/solidAuthProvider2.ts @@ -0,0 +1,111 @@ +import { login, logout, getDefaultSession, handleIncomingRedirect, fetch } from '@inrupt/solid-client-authn-browser'; +import { SemanticDataProvider } from '@semapps/semantic-data-provider'; +import { AuthProvider, UserIdentity } from 'react-admin'; + +interface SolidAuthProviderSettings { + dataProvider: SemanticDataProvider; + allowAnonymous?: boolean; + checkUser?: (userData: any) => boolean; + clientId: string; + clientName: string; +} + +const solidAuthProvider = ({ + dataProvider, + allowAnonymous = true, + checkUser, + clientId, + clientName +}: SolidAuthProviderSettings): AuthProvider => ({ + login: async (params: any) => { + let { webId, issuer, redirect = '/', isSignup = false } = params; + + // if (webId && !issuer) { + // // Find issuer from webId + // const { json: userData } = await dataProvider.fetch(webId); + // if (!userData) throw new Error('auth.message.unable_to_fetch_user_data'); + // if (!userData['solid:oidcIssuer']) throw new Error('auth.message.no_associated_oidc_issuer'); + // issuer = userData?.['solid:oidcIssuer']; + // } + + localStorage.setItem('redirect', redirect); + + await login({ + oidcIssuer: issuer, + redirectUrl: `${window.location.origin}/auth-callback`, + clientName + // customScopes: ['openid', 'webid', 'offline_access'] + }); + }, + handleCallback: async () => { + const redirect = localStorage.getItem('redirect'); + + // Remove we don't need it anymore + localStorage.removeItem('redirect'); + + await handleIncomingRedirect(); + + // Reload to ensure the dataServer config is reset + window.location.href = redirect || '/'; + }, + signup: async (params: any) => { + // Not implemented ? + }, + logout: async (params: any) => { + const { redirectUrl }: { redirectUrl: string } = params || {}; + + await logout({ logoutType: 'idp', postLogoutUrl: redirectUrl }); + }, + checkAuth: async () => { + const isLogged = !getDefaultSession().info.isLoggedIn; + if (!isLogged && !allowAnonymous) throw new Error(); + }, + checkUser: (userData: any) => { + if (checkUser) { + return checkUser(userData); + } + return true; + }, + checkError: (error: any) => { + // We want to disconnect only with INVALID_TOKEN errors + if (error.status === 401 && error.body && error.body.type === 'INVALID_TOKEN') { + localStorage.removeItem('token'); + return Promise.reject(); + } else { + // Other error code (404, 500, etc): no need to log out + return Promise.resolve(); + } + }, + getIdentity: async (): Promise => { + let { webId } = getDefaultSession().info; + let webIdData: any = {}; + let profileData: any = {}; + + if (webId) { + const response = await fetch(webId, { headers: new Headers({ 'Content-Type': 'application/ld+json' }) }); + webIdData = await response.json(); + + if (webIdData.url) { + const response = await fetch(webIdData.url, { + headers: new Headers({ 'Content-Type': 'application/ld+json' }) + }); + profileData = await response.json(); + } + } + + return { + id: webId, + fullName: + profileData['vcard:given-name'] || + profileData['pair:label'] || + webIdData['foaf:name'] || + webIdData['pair:label'], + avatar: + profileData['vcard:photo'] || webIdData.image?.url || webIdData.image || webIdData.icon?.url || webIdData.icon, + profileData, + webIdData + }; + } +}); + +export default solidAuthProvider; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index b5421434e..681181b73 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3376,6 +3376,16 @@ jose "^5.1.3" uuid "^11.1.0" +"@inrupt/oidc-client-ext@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@inrupt/oidc-client-ext/-/oidc-client-ext-3.1.1.tgz#49ceaae936d63a48bdffc75f8ab06446505b5b47" + integrity sha512-vftKD2u5nufZTFkdUDMS3Uxj5xNQwArP11OFaALFkq6/3RwCAhe3lwOv8hNzL7Scv98T+KbAErBM0TwGGrS69g== + dependencies: + "@inrupt/oidc-client" "^1.11.6" + "@inrupt/solid-client-authn-core" "^3.1.1" + jose "^5.1.3" + uuid "^11.1.0" + "@inrupt/oidc-client@^1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@inrupt/oidc-client/-/oidc-client-1.11.6.tgz#5b1ba7e84d0ca4da356f44db1ae8056881502027" @@ -3398,6 +3408,17 @@ jose "^5.1.3" uuid "^11.1.0" +"@inrupt/solid-client-authn-browser@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@inrupt/solid-client-authn-browser/-/solid-client-authn-browser-3.1.1.tgz#ac9661e1e694fedc9443c1de13e2010c4e2429b4" + integrity sha512-Wd7TREmvdhTp+Sk88ei3hlg54sG1fNqkkPkuS+2tDBkcsXaViRQAEugVyh5pWRkd1xSFKrEzftb7UYEG4mJ0CQ== + dependencies: + "@inrupt/oidc-client-ext" "^3.1.1" + "@inrupt/solid-client-authn-core" "^3.1.1" + events "^3.3.0" + jose "^5.1.3" + uuid "^11.1.0" + "@inrupt/solid-client-authn-core@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@inrupt/solid-client-authn-core/-/solid-client-authn-core-3.1.0.tgz#18ecec3a4e1dad8c0f7daa53359a499fd8024dee" @@ -3407,6 +3428,15 @@ jose "^5.1.3" uuid "^11.1.0" +"@inrupt/solid-client-authn-core@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@inrupt/solid-client-authn-core/-/solid-client-authn-core-3.1.1.tgz#cd4ae6a6f32cb231a922d005034badab88df9c0c" + integrity sha512-1oDSQCh/pVtPlTyvLQ2uwHo+hpLJF7izg82tjB+Ge8jqGYwkQyId0BrfncpCk//uJXxgRIcfAQp2MhXYbZo80Q== + dependencies: + events "^3.3.0" + jose "^5.1.3" + uuid "^11.1.0" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"