Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ALS-7330][ALS-7329] RAS login and logout button #253

Merged
merged 10 commits into from
Oct 10, 2024
10 changes: 9 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,17 @@ VITE_AUTH_PROVIDER_MODULE_AUTH0_HELPTEXT='Login with your <a href="https://googl

VITE_AUTH_PROVIDER_MODULE_RAS=true
VITE_AUTH_PROVIDER_MODULE_RAS_TYPE=RAS
VITE_AUTH_PROVIDER_MODULE_RAS_IDP=ras
VITE_AUTH_PROVIDER_MODULE_RAS_CLIENTID=12345ABCD
VITE_AUTH_PROVIDER_MODULE_RAS_URI=http://pic-sure.org/ras
VITE_AUTH_PROVIDER_MODULE_RAS_DESCRIPTION="Login with RAS"
VITE_AUTH_PROVIDER_MODULE_RAS_OKTAIDPID=12345ABCDE
VITE_AUTH_PROVIDER_MODULE_RAS_CONNECTION=okta-ras
VITE_AUTH_PROVIDER_MODULE_RAS_DESCRIPTION='Login with Researcher Auth Service (RAS)'
VITE_AUTH_PROVIDER_MODULE_RAS_ALT=false
VITE_AUTH_PROVIDER_MODULE_RAS_IMAGESRC=NIH_2013_logo_vertical_text_removed.svg
VITE_AUTH_PROVIDER_MODULE_RAS_IMAGEALT='NIH 2013 Logo'
VITE_AUTH_PROVIDER_MODULE_RAS_LOGOUTURL=https://authtest.nih.gov/siteminderagent/smlogoutredirector.asp?TARGET=
VITE_AUTH_PROVIDER_MODULE_RAS_OKTALOGOUTURL=https://hms-srce.oktapreview.com/oauth2/default/v1/logout

VITE_AUTH_PROVIDER_MODULE_FENCE=true
VITE_AUTH_PROVIDER_MODULE_FENCE_TYPE=FENCE
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Tests
on:
push:
branches: [main, dev]
branches: [main, release]
pull_request:
branches: [main, dev]
branches: [main, release]
jobs:
test:
runs-on: ubuntu-latest
Expand Down
79 changes: 64 additions & 15 deletions src/lib/auth/RAS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,46 @@ import { browser } from '$app/environment';
import type { AuthData } from '$lib/models/AuthProvider';
import AuthProvider from '$lib/models/AuthProvider';
import * as api from '$lib/api';
import type { User } from '$lib/models/User';
import type { OktaUser } from '$lib/models/User';
import { login as UserLogin } from '$lib/stores/User';

interface RasData extends AuthData {
uri: string;
clientid: string;
state?: string;
idp: string;
oktaidpid: string;
oktalogouturl: string;
}

class RAS extends AuthProvider implements RasData {
uri: string;
clientid: string;
state: string;
oktaidpid: string;
oktalogouturl: string;
idp: string;

constructor(data: RasData) {
super(data);
this.state = localStorage.getItem('state') || 'ras-' + this.generateRandomState();
localStorage.setItem('state', this.state);

if (
data.uri === undefined ||
data.clientid === undefined ||
data.oktaidpid === undefined ||
data.logouturl === undefined ||
data.oktalogouturl === undefined
) {
throw new Error('Missing required RAS parameter(s).');
}

this.uri = data.uri;
this.clientid = data.clientid;
this.state = data.state ?? this.generateRandomState();
this.oktaidpid = data.oktaidpid;
this.idp = data.idp;
this.oktalogouturl = data.oktalogouturl;
}

private generateRandomState() {
Expand All @@ -29,22 +50,30 @@ class RAS extends AuthProvider implements RasData {
return randomPart + timePart;
}

//TODO: create real return types
// eslint-disable-next-line @typescript-eslint/no-unused-vars
authenticate = async (hashParts: string[]): Promise<boolean> => {
const responseMap = this.getResponseMap(hashParts);
const code = responseMap.get('code');
let state = '';
if (browser) {
state = sessionStorage.getItem('state') || '';
}
if (!code || state !== this.state) {
const responseState = responseMap.get('state') || '';
const localState = this.state;

if (!code || localState !== responseState) {
console.debug(
'RAS authentication failed code: ',
code,
' state: ',
responseState,
' localState: ',
localState,
);
return true;
}

try {
const newUser: User = await api.post('psama/okta/authentication', { code });
const newUser: OktaUser = await api.post('psama/authentication/ras', { code });
if (newUser?.token) {
UserLogin(newUser.token);
await UserLogin(newUser.token);
newUser.oktaIdToken && localStorage.setItem('oktaIdToken', newUser.oktaIdToken);
return false;
} else {
return true;
Expand All @@ -59,13 +88,33 @@ class RAS extends AuthProvider implements RasData {
let redirectUrl = '/';
if (browser) {
redirectUrl = this.getRedirectURI();
window.location.href = encodeURI(
`${this.uri}/oauth2/default/v1/authorize?response_type=code&scope=openid&client_id=${this.clientid}&provider=${type}&redirect_uri=${redirectUrl}&state=${this.state}`,
);
redirectUrl = redirectUrl.replace(/\/$/, '');
this.saveState(redirectTo, type, this.idp);
const rasClientID = encodeURIComponent(this.clientid);
const rasIdpId = encodeURIComponent(this.oktaidpid);
window.location.href = `${this.uri}?response_type=code&scope=openid&client_id=${rasClientID}&idp=${rasIdpId}&redirect_uri=${redirectUrl}&state=${this.state}`;
}
};
logout = (): Promise<void> => {
throw new Error('Method not implemented.');
logout = (): Promise<string> => {
localStorage.removeItem('state');

const redirect = encodeURI(
`${window.location.protocol}//${window.location.hostname}${
window.location.port ? ':' + window.location.port : ''
}/login`,
);

const oktaIdToken = localStorage.getItem('oktaIdToken');
const oktaRedirect =
this.oktalogouturl +
'?id_token_hint=' +
oktaIdToken +
'&post_logout_redirect_uri=' +
redirect;

const oktaEncodedRedirect = encodeURIComponent(oktaRedirect);
const logoutUrl = this.logouturl + oktaEncodedRedirect;
return Promise.resolve(logoutUrl);
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/LoginButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
on:click={() => login(redirectTo, provider.type)}
>
{#if imageSrc}
<img src={imageSrc} alt={provider.imageAlt} class="h-8 mr-2" />
<img src={imageSrc} alt={provider.imageAlt} class="mr-2 h-8" />
{/if}
{buttonText}
</button>
Expand Down
20 changes: 18 additions & 2 deletions src/lib/components/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
import { user, userRoutes, logout } from '$lib/stores/User';
import type { Route } from '$lib/models/Route';
import Logo from '$lib/components/Logo.svelte';

import type AuthData from '$lib/models/AuthProvider.ts';
import type AuthProvider from '$lib/models/AuthProvider.ts';
import { browser } from '$app/environment';
import { createInstance } from '$lib/AuthProviderRegistry.ts';
import { onMount } from 'svelte';
let providerData: AuthData;
let providerInstance: AuthProvider | undefined = undefined;
function setDropdown(path: string) {
dropdownPath = path;
}
Expand Down Expand Up @@ -36,6 +42,16 @@
return $page.url.pathname.includes(route.path) ? 'page' : undefined;
};

onMount(async () => {
if (browser && $page) {
const providerType = sessionStorage.getItem('type');
if (providerType) {
providerData = $page.data?.providers.find((p: AuthProvider) => p.type === providerType);
providerInstance = await createInstance(providerData);
}
}
});

$: dropdownPath = '';
</script>

Expand Down Expand Up @@ -116,7 +132,7 @@
id="user-logout-btn"
class="btn variant-ringed-primary"
title="Logout"
on:click={logout}>Logout</button
on:click={() => logout(providerInstance)}>Logout</button
>
</div>
{:else}
Expand Down
4 changes: 3 additions & 1 deletion src/lib/models/AuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface AuthData extends Indexable {
alt: boolean;
imageSrc?: string;
imageAlt?: string;
logouturl?: string;
}

export default class AuthProvider implements AuthData {
Expand All @@ -35,6 +36,7 @@ export default class AuthProvider implements AuthData {
this.alt = data.alt || false;
this.imageSrc = data.imagesrc;
this.imageAlt = data.imagealt;
this.logouturl = data.logouturl;
}

protected getRedirectURI(): string {
Expand Down Expand Up @@ -83,7 +85,7 @@ export default class AuthProvider implements AuthData {
login = async (redirectTo: string, type: string): Promise<void> => {
throw new Error('Method not implemented.');
};
logout = async (): Promise<void> => {
logout = async (): Promise<string | void> => {
throw new Error('Method not implemented.');
};
}
4 changes: 4 additions & 0 deletions src/lib/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export interface ExtendedUser extends User {
roles: string[];
}

export interface OktaUser extends User {
readonly oktaIdToken: string;
}

// TODO: Replace metadata nad query types
/* eslint-disable @typescript-eslint/no-explicit-any */
export function mapExtendedUser(data: any) {
Expand Down
29 changes: 25 additions & 4 deletions src/lib/stores/User.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { get, writable, derived, type Writable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';

import * as api from '$lib/api';
import type { Route } from '$lib/models/Route';
import type { User } from '$lib/models/User';
import { BDCPrivileges, PicsurePrivileges } from '$lib/models/Privilege';
import { routes, features, resources } from '$lib/configuration';
import { goto } from '$app/navigation';
import type { QueryInterface } from '$lib/models/query/Query';
import type AuthProvider from '$lib/models/AuthProvider.ts';

export const user: Writable<User> = writable(restoreUser());

Expand Down Expand Up @@ -134,14 +134,35 @@ export async function login(token: string) {
}
}

export async function logout() {
export async function logout(authProvider: AuthProvider | undefined) {
if (browser) {
const token = localStorage.getItem('token');
token && api.get('/psama/logout');
token && localStorage.removeItem('token');
}
user.set({});
goto('/login');

// get the auth provider
if (authProvider) {
authProvider
.logout()
.then((redirect) => {
user.set({});
if (typeof redirect === 'string') {
location.replace(redirect);
} else {
// If no redirect is provided, go to the login page
console.error('Error logging out: ' + redirect);
goto('/login');
}
})
.catch((error) => {
console.error('Error logging out: ' + error);
goto('/login');
});
Gcolon021 marked this conversation as resolved.
Show resolved Hide resolved
} else {
user.set({});
goto('/login');
}
}

export function isTokenExpired(token: string) {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(authentication)/login/loading/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@
failed = true;
}
if (!provider || failed) {
console.error('Provider not found');
goto('/login/error');
}
const providerInstance = await createInstance(provider);
let hashParts = $page.url.hash?.split('&') || [];
if ($page.url.search.startsWith('?')) {
hashParts = $page.url.search.substring(1).split('&') || [];
}
console.log('hashParts', hashParts);

failed = await providerInstance.authenticate(hashParts);

let filtersJson = sessionStorage.getItem('filters');
Expand Down
9 changes: 9 additions & 0 deletions src/routes/(picsure)/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { getAllProviderData } from '$lib/AuthProviderRegistry.ts';

export const load: LayoutServerLoad = async () => {
const providers = getAllProviderData();
return {
providers: providers,
};
};