Skip to content

Commit

Permalink
Merge pull request #398 from sebadob/feat-link-provider-existing-acc
Browse files Browse the repository at this point in the history
feat: link provider to existing account
  • Loading branch information
sebadob committed May 2, 2024
2 parents 50d0214 + b9cd8ce commit fdc683c
Show file tree
Hide file tree
Showing 17 changed files with 428 additions and 47 deletions.
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,19 @@ at the bottom as well.
A new button has been introduced to the account view of federated accounts.
You can now "Unlink" an account from an upstream provider, if you have set it up with at least
a password or passkey before.

[8b1d9a8](https://github.com/sebadob/rauthy/commit/8b1d9a882b0d4b059f3ed884deaacfcdeb109856)

#### Link Existing Account to Provider

This is the counterpart to the unlink feature from above.
This makes it possible to link an already existing, unlinked user account to an upstream auth provider.
The only condition is a matching `email` claim after successful login. Apart from that, there are quite a few things
going on behind the scenes and you must trigger this provider link from an authorized, valid session from inside your
user account view. This is necessary to prevent account takeovers if an upstream provider has been hacked in some way.

[]()

#### Bootstrap default Admin in production

You can set environment variables either via `rauthy.cfg`, `.env` or as just an env var during
Expand Down Expand Up @@ -200,7 +211,8 @@ The allowed names for roles, groups and scopes have been adjusted. Rauthy allows
now and containing `:` or `*`. This will make it possible to define custom scopes with names like
`urn:matrix:client:api:guest` or `urn:matrix:client:api:*`.
[]()
[a5982d9](https://github.com/sebadob/rauthy/commit/a5982d91f37a2f2917ed4215dc6ded216dc0fd69)
[50d0214](https://github.com/sebadob/rauthy/commit/50d021440eb50473977ec851a46c0bc979bbd12b)
### Bugfixes
Expand Down
3 changes: 1 addition & 2 deletions dev_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@

## TODO before v0.23.0

- link to upstream provider with existing account
- sessions view needs pagination
- check out the possibility to include SCIM

## Stage 1 - essentials

Expand All @@ -15,6 +13,7 @@
## Stage 2 - features - do before v1.0.0

- prettify the UI
- check out the possibility to include SCIM
- update the book with all the new features
- benchmarks and performance tuning
- maybe get a nicer logo
Expand Down
114 changes: 106 additions & 8 deletions frontend/src/components/account/AccInfo.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script>
import CheckIcon from "$lib/CheckIcon.svelte";
import {buildWebIdUri, formatDateFromTs} from "../../utils/helpers.js";
import {buildWebIdUri, formatDateFromTs, saveProviderToken} from "../../utils/helpers.js";
import {onMount} from "svelte";
import {getAuthProvidersTemplate} from "../../utils/helpers.js";
import Button from "$lib/Button.svelte";
import Tooltip from "$lib/Tooltip.svelte";
import {deleteUserProviderLink} from "../../utils/dataFetching.js";
import {deleteUserProviderLink, postUserProviderLink} from "../../utils/dataFetching.js";
import Modal from "$lib/Modal.svelte";
import getPkce from "oauth-pkce";
import {PKCE_VERIFIER_UPSTREAM} from "../../utils/constants.js";
export let t;
export let user = {};
Expand All @@ -15,13 +16,60 @@
export let viewModePhone = false;
let unlinkErr = false;
let showModal = false;
let providersAvailable = [];
$: isFederated = user.account_type.startsWith('federated');
$: isFederated = user.account_type?.startsWith('federated');
$: accType = isFederated ? `${user.account_type}: ${authProvider?.name || ''}` : user.account_type;
$: classRow = viewModePhone ? 'rowPhone' : 'row';
$: classLabel = viewModePhone ? 'labelPhone' : 'label';
onMount(() => {
// value for dev testing only
const providersTpl = [{
"id": "7F6N7fb3el3P5XimjJSaeD2o",
"name": "Rauthy IAM"
}];
// const providersTpl = document?.getElementsByTagName("template").namedItem("auth_providers")?.innerHTML;
providersAvailable = providersTpl;
})
function linkProvider(id) {
getPkce(64, (error, {challenge, verifier}) => {
if (!error) {
localStorage.setItem(PKCE_VERIFIER_UPSTREAM, verifier);
providerLoginPkce(id, challenge);
}
});
}
async function providerLoginPkce(id, pkce_challenge) {
let data = {
email: user.email,
client_id: 'rauthy',
redirect_uri: window.location.href,
// scopes: '',
// state: state,
// nonce: nonce,
// code_challenge: challenge,
// code_challenge_method: challengeMethod,
provider_id: id,
pkce_challenge,
};
let res = await postUserProviderLink(id, data);
if (res.ok) {
const xsrfToken = await res.text();
saveProviderToken(xsrfToken);
window.location.href = res.headers.get('location');
} else {
let body = await res.json();
// TODO catch error even necessary? should be handled in `/callback` already...
console.error(body);
}
}
async function unlinkProvider() {
let res = await deleteUserProviderLink();
let body = await res.json();
Expand Down Expand Up @@ -70,8 +118,39 @@
</div>
{/if}
</div>
{:else}
<!-- TODO -->
{:else if providersAvailable.length > 0}
<div
role="button"
tabindex="0"
class="provider-link"
on:click={() => showModal = !showModal}
on:keypress={() => showModal = !showModal}
>
{t.providerLink}
</div>
<Modal bind:showModal>
<p>
{t.providerLinkDesc}
</p>
<div class="providers">
{#each providersAvailable as provider (provider.id)}
<Button on:click={() => linkProvider(provider.id)} level={3}>
<div class="flex-inline">
<img
src="{`/auth/v1/providers/${provider.id}/img`}"
alt=""
width="20"
height="20"
/>
<span class="provider-name">
{provider.name}
</span>
</div>
</Button>
{/each}
</div>
</Modal>
{/if}
</div>
</div>
Expand Down Expand Up @@ -133,7 +212,6 @@
</span>
</div>
{/if}
</div>
<style>
Expand All @@ -154,11 +232,31 @@
margin-left: -5px;
}
.flex-inline {
display: inline-flex;
align-items: center;
gap: .5rem;
}
.link-err {
margin-left: 5px;
color: var(--col-err);
}
.provider-link {
color: var(--col-act2);
cursor: pointer;
}
.provider-name {
margin-bottom: -4px;
}
.providers {
margin-top: .66rem;
display: flex;
}
.row {
display: flex;
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccMain.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
}
$: if (providers) {
if (user.account_type.startsWith('federated')) {
if (user.account_type?.startsWith('federated')) {
authProvider = providers.filter(p => p.id === user.auth_provider_id)[0];
}
}
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/lib/Modal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script>
import IconStop from "$lib/icons/IconStop.svelte";
/** @type {boolean} */
export let showModal;
/** @type {HTMLDialogElement} */
let dialog;
$: if (dialog && showModal) dialog.showModal();
</script>

<!-- According to MDN docs, a dialog element must not have a tabindex -->
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog
bind:this={dialog}
on:close={() => (showModal = false)}
on:click|self={() => dialog.close()}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|stopPropagation>
<div
role="button"
tabindex="0"
class="close"
on:click={() => dialog.close()}
>
<IconStop color="var(--col-err)" width={24}/>
</div>
<div class="inner">
<!--
Just make sure that whatever we have in here will be loaded / fetched lazy.
There is no need to load resources like images if the dialog is closed anyway.
-->
{#if showModal}
<slot></slot>
{/if}
</div>
</div>
</dialog>

<style>
dialog {
border-radius: 3px;
border: none;
padding: 0;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.3);
}
dialog > div {
padding: 1rem;
}
dialog > div > div {
position: relative;
}
dialog[open] {
animation: zoom 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes zoom {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
dialog[open]::backdrop {
animation: fade 0.2s ease-out;
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.close {
margin: -1rem -1rem 0 0;
cursor: pointer;
text-align: right;
}
.inner {
margin-top: -.5rem;
}
</style>
7 changes: 6 additions & 1 deletion frontend/src/routes/oidc/authorize/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@
<Button on:click={() => providerLogin(provider.id)} level={3}>
<div class="flex-inline">
<img src="{`/auth/v1/providers/${provider.id}/img`}" alt="" width="20" height="20"/>
{provider.name}
<span class="providerName">{provider.name}</span>
</div>
</Button>
{/each}
Expand Down Expand Up @@ -515,6 +515,11 @@
margin-top: .66rem;
}
.providerName {
/*margin-bottom: -14px;*/
margin-top: 4px;
}
.reg {
margin-left: 5px;
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/routes/providers/callback/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
// -> all good, but needs additional passkey validation
error = '';
webauthnData = await res.json();
} else if (res.status === 204) {
// in case of a 204, we have done a user federation on an existing account -> just redirect
window.location.replace('/auth/v1/account');
} else if (res.status === 403) {
// we will get a forbidden if for instance the user already exists but without
// any upstream provider link (or the wrong one)
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/utils/dataFetching.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ export async function getUserPasskeys(id) {
});
}

export async function postUserProviderLink(id, data) {
return await fetch(`/auth/v1/providers/${id}/link`, {
method: 'POST',
headers: getCsrfHeaders(),
body: JSON.stringify(data),
});
}

export async function deleteUserProviderLink() {
return await fetch('/auth/v1/providers/link', {
method: 'DELETE',
Expand Down
1 change: 1 addition & 0 deletions rauthy-common/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub const COOKIE_SESSION: &str = "rauthy-session";
pub const COOKIE_MFA: &str = "rauthy-mfa";
pub const COOKIE_LOCALE: &str = "locale";
pub const COOKIE_UPSTREAM_CALLBACK: &str = "upstream_auth_callback";
pub const PROVIDER_LINK_COOKIE: &str = "rauthy-provider-link";
pub const PWD_RESET_COOKIE: &str = "rauthy-pwd-reset";
pub const APP_ID_HEADER: &str = "mfa-app-id";
pub const CSRF_HEADER: &str = "csrf-token";
Expand Down
Loading

0 comments on commit fdc683c

Please sign in to comment.