authorize view

This commit is contained in:
Evert Prants 2024-05-18 16:21:16 +03:00
parent 610b3a8c09
commit d47b68736d
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
11 changed files with 225 additions and 87 deletions

View File

@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
export let user: { uuid: string; username: string }; export let src = '';
export let cacheBust: number | undefined = undefined; export let alt = '';
$: avatarSource = `/api/avatar/${user.uuid}${cacheBust ? `?t=${cacheBust}` : ''}`;
</script> </script>
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}"> <div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
<div class="image-wrapper"> <div class="image-wrapper">
<img src={avatarSource} alt={user.username} /> <img {src} {alt} />
</div> </div>
<div class="actions-wrapper"> <div class="actions-wrapper">

View File

@ -2,15 +2,22 @@
"authorize": { "authorize": {
"title": "Authorize application", "title": "Authorize application",
"errorPage": "The authorization URL provided is invalid or malformed. Please forward this message to the developers of the application you came here from:", "errorPage": "The authorization URL provided is invalid or malformed. Please forward this message to the developers of the application you came here from:",
"authorize": "Authorize", "authorize": "Authorize the application",
"reject": "Reject", "reject": "Reject",
"allowed": "This application will have access to..",
"disallowed": "This application will NOT have access to..",
"scope": { "scope": {
"profile": "Username and display name", "profile": "Your username and display name",
"email": "Email address", "email": "Your email address",
"picture": "Profile picture", "picture": "Your profile picture",
"account": "Password and other account settings", "account": "Changing your password or other account settings",
"management": "Manage Icy Network on your behalf", "management": "Manage Icy Network on your behalf",
"admin": "Commit administrative actions to the extent of your user privileges" "admin": "Commit administrative actions to the extent of your user privileges"
},
"link": {
"website": "Website",
"privacy": "Privacy policy",
"terms": "Terms of Service"
} }
} }
} }

View File

@ -0,0 +1,47 @@
import { AccessDenied } from '../error';
import { OAuth2AccessTokens, type OAuth2AccessToken } from '../model';
export class OAuth2BearerController {
static bearerFromRequest = async (
request: Request,
url: URL
): Promise<OAuth2AccessToken | undefined> => {
let token = null;
// Look for token in header
if (request.headers.has('authorization')) {
const pieces = (request.headers.get('authorization') as string).split(' ', 2);
// Check authorization header
if (!pieces || pieces.length !== 2) {
throw new AccessDenied('Wrong authorization header');
}
// Only bearer auth is supported
if (pieces[0].toLowerCase() !== 'bearer') {
throw new AccessDenied('Unsupported authorization method in header');
}
token = pieces[1];
} else if (request.headers.has('x-access-token')) {
token = request.headers.get('x-access-token');
} else if (url?.searchParams.has('access_token')) {
token = url.searchParams.get('access_token') as string;
} else {
const body = await request.json().catch(() => ({}));
if (!body.access_token) {
throw new AccessDenied('Bearer token not found');
}
token = body.access_token;
}
// Try to fetch access token
const object = await OAuth2AccessTokens.fetchByToken(token);
if (!object) {
throw new AccessDenied('Token not found or has expired');
} else if (!OAuth2AccessTokens.checkTTL(object)) {
throw new AccessDenied('Token is expired');
}
return object;
};
}

View File

@ -1,3 +1,4 @@
export * from './authorization'; export * from './authorization';
export * from './introspection'; export * from './introspection';
export * from './token'; export * from './token';
export * from './bearer';

View File

@ -15,9 +15,9 @@ import type { OAuth2TokenResponse } from '../../response';
/** /**
* Issue an access token by authorization code * Issue an access token by authorization code
* @param oauth2 - OAuth2 instance
* @param client - OAuth2 client * @param client - OAuth2 client
* @param providedCode - Authorization code * @param providedCode - Authorization code
* @param codeVerifier - PCKE check verifier
* @returns Access token. * @returns Access token.
*/ */
export async function authorizationCode( export async function authorizationCode(

View File

@ -5,7 +5,6 @@ import type { OAuth2TokenResponse } from '../../response';
/** /**
* Issue client access token * Issue client access token
* @param oauth2 - OAuth2 instance
* @param client - Client * @param client - Client
* @param wantScope - Requested scopes * @param wantScope - Requested scopes
* @returns Access token * @returns Access token

View File

@ -12,7 +12,6 @@ import type { OAuth2TokenResponse } from '../../response';
/** /**
* Get a new access token from a refresh token. Scope change may not be requested. * Get a new access token from a refresh token. Scope change may not be requested.
* @param oauth2 - OAuth2 instance
* @param client - OAuth2 client * @param client - OAuth2 client
* @param providedToken - Refresh token * @param providedToken - Refresh token
* @returns Access token * @returns Access token

View File

@ -10,7 +10,6 @@ import { and, eq, sql } from 'drizzle-orm';
import { OAuth2Clients } from './client'; import { OAuth2Clients } from './client';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { CryptoUtils } from '$lib/server/crypto-utils'; import { CryptoUtils } from '$lib/server/crypto-utils';
import { AccessDenied } from '../error';
export type CodeChallengeMethod = 'plain' | 'S256'; export type CodeChallengeMethod = 'plain' | 'S256';
@ -254,46 +253,6 @@ export class OAuth2AccessTokens {
clientIdPub: find.o_auth2_client.client_id clientIdPub: find.o_auth2_client.client_id
}; };
} }
static async getFromRequest(request: Request, url?: URL): Promise<OAuth2AccessToken | undefined> {
let token = null;
// Look for token in header
if (request.headers.has('authorization')) {
const pieces = (request.headers.get('authorization') as string).split(' ', 2);
// Check authorization header
if (!pieces || pieces.length !== 2) {
throw new AccessDenied('Wrong authorization header');
}
// Only bearer auth is supported
if (pieces[0].toLowerCase() !== 'bearer') {
throw new AccessDenied('Unsupported authorization method in header');
}
token = pieces[1];
} else if (request.headers.has('x-access-token')) {
token = request.headers.get('x-access-token');
} else if (url?.searchParams.has('access_token')) {
token = url.searchParams.get('access_token') as string;
} else {
const body = await request.json().catch(() => ({}));
if (!body.access_token) {
throw new AccessDenied('Bearer token not found');
}
token = body.access_token;
}
// Try to fetch access token
const object = await OAuth2AccessTokens.fetchByToken(token);
if (!object) {
throw new AccessDenied('Token not found or has expired');
} else if (!OAuth2AccessTokens.checkTTL(object)) {
throw new AccessDenied('Token is expired');
}
return object;
}
} }
export class OAuth2RefreshTokens { export class OAuth2RefreshTokens {

View File

@ -155,7 +155,7 @@
<ViewColumn slot="side"> <ViewColumn slot="side">
<div> <div>
<h3>{$t('account.avatar.title')}</h3> <h3>{$t('account.avatar.title')}</h3>
<AvatarCard user={data.user} cacheBust={data.updateRef}> <AvatarCard src={`/api/avatar/${data.user.uuid}?t=${data.updateRef}`} alt={data.user.name}>
<ViewColumn> <ViewColumn>
<Button variant="primary" on:click={() => ($showAvatarModal = true)} <Button variant="primary" on:click={() => ($showAvatarModal = true)}
>{$t('account.avatar.change')}</Button >{$t('account.avatar.change')}</Button

View File

@ -1,9 +1,9 @@
import { PUBLIC_URL } from '$env/static/public'; import { PUBLIC_URL } from '$env/static/public';
import { ApiUtils } from '$lib/server/api-utils.js'; import { ApiUtils } from '$lib/server/api-utils.js';
import type { User } from '$lib/server/drizzle/schema.js'; import type { User } from '$lib/server/drizzle/schema.js';
import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js';
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js'; import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Clients } from '$lib/server/oauth2/model/client.js'; import { OAuth2Clients } from '$lib/server/oauth2/model/client.js';
import { OAuth2AccessTokens } from '$lib/server/oauth2/model/tokens.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
@ -17,7 +17,7 @@ export const GET = async ({ request, url, locals }) => {
} else { } else {
// From OAuth2 bearer token // From OAuth2 bearer token
try { try {
const token = await OAuth2AccessTokens.getFromRequest(request, url); const token = await OAuth2BearerController.bearerFromRequest(request, url);
if (token?.userId) { if (token?.userId) {
tokenScopes = OAuth2Clients.splitScope(token.scope || ''); tokenScopes = OAuth2Clients.splitScope(token.scope || '');
user = await Users.getById(token.userId); user = await Users.getById(token.userId);

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { assets } from '$app/paths';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte'; import ColumnView from '$lib/components/ColumnView.svelte';
@ -12,9 +12,8 @@
</script> </script>
<MainContainer> <MainContainer>
<h1>{$t('common.siteName')}</h1>
{#if data.error} {#if data.error}
<h1>{$t('common.siteName')}</h1>
<ColumnView> <ColumnView>
<Alert type="error" <Alert type="error"
>{$t('oauth2.authorize.errorPage')}<br /><br /><code >{$t('oauth2.authorize.errorPage')}<br /><br /><code
@ -26,38 +25,167 @@
{/if} {/if}
{#if data.client} {#if data.client}
<h2>{$t('oauth2.authorize.title')}</h2> <h1 class="title">{$t('common.siteName')}</h1>
{#if data.user} <h2 class="title">{$t('oauth2.authorize.title')}</h2>
<AvatarCard user={data.user}>
<ColumnView>
<span class="user-display-name">{data.user.name}</span>
<span class="user-user-name">@{data.user.username}</span>
</ColumnView>
</AvatarCard>
{/if}
{data.client.title}
{data.client.description}
<div class="scope-list scope-list-allowed"> <div class="user-client-wrapper">
{#each data.client.allowedScopes as scope} {#if data.user}
{$t(`oauth2.authorize.scope.${scope}`)} <div class="card">
{/each} <AvatarCard src={`/api/avatar/${data.user.uuid}`} alt={data.user.name}>
<div class="card-inner">
<span class="card-display-name">{data.user.name}</span>
<span class="card-user-name">@{data.user.username}</span>
</div>
</AvatarCard>
</div>
{/if}
<div class="graphic" aria-hidden="true"></div>
<div class="card">
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
<div class="card-inner">
<span class="card-display-name">{data.client.title}</span>
<span class="card-user-name">{data.client.description}</span>
<div class="card-links">
{#each data.client.links as link}
<a href={link.url} target="_blank" rel="nofollow noreferrer"
>{$t(`oauth2.authorize.link.${link.type}`)}</a
>
{/each}
</div>
</div>
</AvatarCard>
</div>
</div> </div>
<div class="scope-list scope-list-disallowed"> <div class="scope">
{#each data.client.disallowedScopes as scope} {#if data.client.allowedScopes?.length}
{$t(`oauth2.authorize.scope.${scope}`)} <h3>{$t('oauth2.authorize.allowed')}</h3>
{/each} <div class="scope-list scope-list-allowed">
{#each data.client.allowedScopes as scope}
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
{/each}
</div>
{/if}
{#if data.client.disallowedScopes?.length}
<h3>{$t('oauth2.authorize.disallowed')}</h3>
<div class="scope-list scope-list-disallowed">
{#each data.client.disallowedScopes as scope}
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
{/each}
</div>
{/if}
</div> </div>
<form action="" method="POST"> <div class="decision">
<input type="hidden" value="1" name="decision" /> <form action="" method="POST">
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button> <input type="hidden" value="1" name="decision" />
</form> <Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
</form>
<form action="" method="POST"> <form action="" method="POST">
<input type="hidden" value="0" name="decision" /> <input type="hidden" value="0" name="decision" />
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button> <Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
</form> </form>
</div>
{/if} {/if}
</MainContainer> </MainContainer>
<style>
.title {
text-align: center;
}
.user-client-wrapper {
display: flex;
flex-direction: row;
justify-content: space-evenly;
& .graphic {
background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27currentColor%27 d=%27M10,4H14V13L17.5,9.5L19.92,11.92L12,19.84L4.08,11.92L6.5,9.5L10,13V4Z%27 /%3E%3C/svg%3E');
filter: invert();
transform: rotate(-90deg);
width: 80px;
height: 80px;
opacity: 0.4;
margin: 20px;
flex-shrink: 0;
}
& .card {
width: 40%;
}
@media screen and (max-width: 768px) {
flex-direction: column;
align-items: center;
& .graphic {
transform: none;
}
& .card {
width: 100%;
}
}
}
.card-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.card-display-name {
font-size: 1.25rem;
font-weight: 700;
}
.card-links {
display: flex;
flex-direction: column;
margin-top: auto;
}
.scope {
max-width: 600px;
margin: 2rem auto;
}
.scope-list {
display: flex;
flex-direction: column;
}
.scope-list-allowed {
margin-bottom: 1rem;
& .scope-list-item::before {
content: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%2300f000%27 d=%27M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z%27 /%3E%3C/svg%3E');
}
}
.scope-list-disallowed .scope-list-item::before {
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' style='width:24px;height:24px' viewBox='0 0 24 24'%3E%3Cpath fill='%23f00000' d='M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z' /%3E%3C/svg%3E");
}
.scope-list-item {
display: flex;
align-items: center;
font-weight: 700;
&::before {
display: block;
width: 32px;
height: 32px;
background-position: center;
background-repeat: no-repeat;
margin: 4px;
flex-shrink: 0;
}
}
.decision {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
</style>