Skip to content

Commit

Permalink
feat: allow linking and unlinking existing accounts to 3rd party auth…
Browse files Browse the repository at this point in the history
… providers.
  • Loading branch information
zicklag committed Jan 18, 2025
1 parent 6817df3 commit e0708a6
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 28 deletions.
15 changes: 15 additions & 0 deletions src/lib/rauthy/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { checkResponse } from '$lib/utils/http';

/** Get a proof-of-work challenge from the auth server. */
export async function get_pow_challenge(): Promise<string> {
return await (await fetch('/auth/weird/pow')).text();
}

export interface Provider {
id: string;
name: string;
}

export async function getProviders(fetch: typeof globalThis.fetch): Promise<Provider[]> {
let providers: Provider[] = [];
const providersResp = await fetch('/auth/v1/providers/minimal');
await checkResponse(providersResp);
providers = await providersResp.json();
return providers;
}
23 changes: 14 additions & 9 deletions src/lib/rauthy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,25 @@ export async function getSession(
return { sessionInfo /*, userInfo */ };
}

export async function getUserInfoFromSession(
fetch: typeof globalThis.fetch,
request: Request,
sessionInfo: SessionInfo
): Promise<UserInfo> {
const userInfoResp = await fetch(`/auth/v1/users/${sessionInfo?.user_id}`, {
headers: cleanHeaders(request)
});
await checkResponse(userInfoResp);
return await userInfoResp.json();
}

export async function getUserInfo(
fetch: typeof window.fetch,
request: Request
): Promise<{ sessionInfo?: SessionInfo; userInfo?: UserInfo }> {
const { sessionInfo } = await getSession(fetch, request);
let userInfo: UserInfo | undefined = undefined;

try {
const userInfoResp = await fetch(`/auth/v1/users/${sessionInfo?.user_id}`, {
headers: cleanHeaders(request)
});
await checkResponse(userInfoResp);
userInfo = await userInfoResp.json();
} catch (_) {}
if (!sessionInfo) return {};
let userInfo: UserInfo = await getUserInfoFromSession(fetch, request, sessionInfo);

return { userInfo };
}
Expand Down
9 changes: 8 additions & 1 deletion src/routes/(app)/[username]/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { getProfile, listChildren } from '$lib/leaf/profile';
import { error, redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from '../$types';
import { getSession } from '$lib/rauthy/server';
import { getSession, getUserInfoFromSession } from '$lib/rauthy/server';
import { leafClient, subspace_link } from '$lib/leaf';
import { Name } from 'leaf-proto/components';
import { usernames } from '$lib/usernames/index';
import { base32Encode } from 'leaf-proto';
import { billing, type UserSubscriptionInfo } from '$lib/billing';
import { verifiedLinks } from '$lib/verifiedLinks';
import { getProviders } from '$lib/rauthy/client';

export const load: LayoutServerLoad = async ({ fetch, params, request, url }) => {
const username = usernames.shortNameOrDomain(params.username!);
Expand Down Expand Up @@ -57,7 +58,13 @@ export const load: LayoutServerLoad = async ({ fetch, params, request, url }) =>
? await usernames.getDomainVerificationJob(sessionInfo.user_id)
: undefined;

let providers = await getProviders(fetch);

const userInfo = sessionInfo ? await getUserInfoFromSession(fetch, request, sessionInfo) : undefined;

return {
userInfo,
providers,
profile,
verifiedLinks: await verifiedLinks.get(fullUsername),
profileMatchesUserSession,
Expand Down
20 changes: 20 additions & 0 deletions src/routes/(app)/[username]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import SetHandleModal from './components/ChangeHandleModal.svelte';
import ManageSubscriptionModal from './components/ManageSubscriptionModal.svelte';
import DeleteProfileModal from './components/DeleteProfileModal.svelte';
import ManageAccountModal from './components/ManageAccountModal.svelte';
import { goto } from '$app/navigation';
const { data, children }: { children: Snippet; data: PageData } = $props();
Expand Down Expand Up @@ -39,6 +40,19 @@
}
}
});
const manageAccountModal: ModalSettings = $derived({
type: 'component',
component: { ref: ManageAccountModal },
userInfo: data.userInfo,
providers: data.providers,
async response(r) {
if ('error' in r) {
error = r.error;
} else {
error = null;
}
}
});
const deleteProfileModal: ModalSettings = $derived({
type: 'component',
component: { ref: DeleteProfileModal },
Expand Down Expand Up @@ -94,6 +108,12 @@
>
Manage Subscription
</button>
<button
class="variant-outline btn"
onclick={() => modalStore.trigger(manageAccountModal)}
>
Manage Account
</button>
<button class="variant-outline btn" onclick={() => modalStore.trigger(setHandleModal)}>
Change Handle
</button>
Expand Down
115 changes: 115 additions & 0 deletions src/routes/(app)/[username]/components/ManageAccountModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script lang="ts">
import { getModalStore } from '@skeletonlabs/skeleton';
import type { UserInfo } from '$lib/rauthy';
import type { Provider } from '$lib/rauthy/client';
import getPkce from 'oauth-pkce';
const modalStore = getModalStore();
let userInfo = $state({}) as UserInfo | undefined;
let providers = $state([]) as Provider[];
$effect(() => {
userInfo = ($modalStore[0] as any).userInfo;
providers = ($modalStore[0] as any).providers;
});
let federated = $derived(userInfo?.account_type?.startsWith('federated') || false);
let currentProviderName = $derived(
providers.find((x) => x.id == userInfo?.auth_provider_id)?.name
);
const getKey = (i: number) => {
let res = '';
const target = i || 8;
for (let i = 0; i < target; i += 1) {
let nextNumber = 60;
while ((nextNumber > 57 && nextNumber < 65) || (nextNumber > 90 && nextNumber < 97)) {
nextNumber = Math.floor(Math.random() * 74) + 48;
}
res = res.concat(String.fromCharCode(nextNumber));
}
return res;
};
async function providerLinkPkce(provider_id: string, pkce_challenge: string) {
const data = {
pkce_challenge,
redirect_uri: window.location.href,
client_id: 'rauthy',
email: userInfo?.email,
provider_id
};
await fetch(`/auth/v1/providers/${provider_id}/link`, {
method: 'POST',
headers: [['csrf-token', localStorage.getItem('csrfToken')!]],
body: JSON.stringify(data)
})
.then(() => {
getPkce(64, async (error, { challenge, verifier }) => {
if (!error) {
localStorage.setItem('pkce_verifier', verifier);
const nonce = getKey(24);
const s = 'account';
const redirect_uri = encodeURIComponent(
`${window.location.origin}/auth/v1/oidc/callback`
);
window.location.href = `/auth/v1/oidc/logout?post_logout_redirect_uri=%2Fauth%2Fv1%2Foidc%2Fauthorize%3Fclient_id%3Drauthy%26redirect_uri%3D${redirect_uri}%26response_type%3Dcode%26code_challenge%3D${challenge}%26code_challenge_method%3DS256%26scope%3Dopenid%2Bprofile%2Bemail%26nonce%3D${nonce}%26state%3D${s}`;
}
});
})
.catch((err) => console.log(err, 'a'));
}
async function linkAccount(providerId: string) {
getPkce(64, (error, { challenge, verifier }) => {
if (!error) {
localStorage.setItem('pkceVerifierUpstream', verifier);
providerLinkPkce(providerId, challenge);
}
});
}
async function unlinkAccount() {
await fetch(`/auth/v1/providers/link`, {
method: 'delete',
headers: [['csrf-token', localStorage.getItem('csrfToken')!]]
});
window.location.reload();
}
</script>

{#if $modalStore[0]}
<div class="card flex flex-col justify-between p-4 shadow-xl">
<div>
<header class="text-2xl font-bold">Manage Account</header>

<section class="p-2">
<div class="my-4 flex flex-col gap-2">
{#if federated}
<button class="variant-outline btn" onclick={unlinkAccount}>
Disconnect From {currentProviderName}
</button>
{:else if providers.length > 0}
{#each providers as provider}
<button class="variant-outline btn" onclick={() => linkAccount(provider.id)}>
<span>
<img
src={`/auth/v1/providers/${provider.id}/img`}
alt=""
width="20"
height="20"
/>
</span>
<span>
Connect To {provider.name}
</span>
</button>
{/each}
{/if}
</div>
</section>
</div>

<button class="variant-ghost btn" onclick={() => modalStore.close()}> Cancel </button>
</div>
{/if}
17 changes: 2 additions & 15 deletions src/routes/(app)/login/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
import { getSession } from '$lib/rauthy/server';
import { checkResponse } from '$lib/utils/http';
import { getProviders, type Provider } from '$lib/rauthy/client';
import type { PageServerLoad } from './$types';

export interface Provider {
id: string;
name: string;
}

export const load: PageServerLoad = async ({ fetch }): Promise<{ providers: Provider[] }> => {
let providers: Provider[] = [];
try {
const providersResp = await fetch('/auth/v1/providers/minimal');
await checkResponse(providersResp);
providers = await providersResp.json();
} catch (e) {
console.error('Error getting providers:', e);
}
let providers = await getProviders(fetch);

return { providers };
};
4 changes: 1 addition & 3 deletions src/routes/(app)/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
const { data }: { data: PageData } = $props();
const providers = data.providers;
const PKCE_VERIFIER = 'pkce_verifier';
let clientId = $state('');
let redirectUri = $state('');
let nonce = $state('');
Expand Down Expand Up @@ -108,7 +106,7 @@
clientId = 'rauthy';
getPkce(64, async (error, { challenge: c, verifier }) => {
if (!error) {
localStorage.setItem(PKCE_VERIFIER, verifier);
localStorage.setItem("pkce_verifier", verifier);
challengeMethod = 'S256';
challenge = c;
nonce = getKey(24);
Expand Down

0 comments on commit e0708a6

Please sign in to comment.