authorize view
This commit is contained in:
parent
610b3a8c09
commit
d47b68736d
@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let user: { uuid: string; username: string };
|
||||
export let cacheBust: number | undefined = undefined;
|
||||
|
||||
$: avatarSource = `/api/avatar/${user.uuid}${cacheBust ? `?t=${cacheBust}` : ''}`;
|
||||
export let src = '';
|
||||
export let alt = '';
|
||||
</script>
|
||||
|
||||
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
|
||||
<div class="image-wrapper">
|
||||
<img src={avatarSource} alt={user.username} />
|
||||
<img {src} {alt} />
|
||||
</div>
|
||||
|
||||
<div class="actions-wrapper">
|
||||
|
@ -2,15 +2,22 @@
|
||||
"authorize": {
|
||||
"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:",
|
||||
"authorize": "Authorize",
|
||||
"authorize": "Authorize the application",
|
||||
"reject": "Reject",
|
||||
"allowed": "This application will have access to..",
|
||||
"disallowed": "This application will NOT have access to..",
|
||||
"scope": {
|
||||
"profile": "Username and display name",
|
||||
"email": "Email address",
|
||||
"picture": "Profile picture",
|
||||
"account": "Password and other account settings",
|
||||
"profile": "Your username and display name",
|
||||
"email": "Your email address",
|
||||
"picture": "Your profile picture",
|
||||
"account": "Changing your password or other account settings",
|
||||
"management": "Manage Icy Network on your behalf",
|
||||
"admin": "Commit administrative actions to the extent of your user privileges"
|
||||
},
|
||||
"link": {
|
||||
"website": "Website",
|
||||
"privacy": "Privacy policy",
|
||||
"terms": "Terms of Service"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
47
src/lib/server/oauth2/controller/bearer.ts
Normal file
47
src/lib/server/oauth2/controller/bearer.ts
Normal 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;
|
||||
};
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
export * from './authorization';
|
||||
export * from './introspection';
|
||||
export * from './token';
|
||||
export * from './bearer';
|
||||
|
@ -15,9 +15,9 @@ import type { OAuth2TokenResponse } from '../../response';
|
||||
|
||||
/**
|
||||
* Issue an access token by authorization code
|
||||
* @param oauth2 - OAuth2 instance
|
||||
* @param client - OAuth2 client
|
||||
* @param providedCode - Authorization code
|
||||
* @param codeVerifier - PCKE check verifier
|
||||
* @returns Access token.
|
||||
*/
|
||||
export async function authorizationCode(
|
||||
|
@ -5,7 +5,6 @@ import type { OAuth2TokenResponse } from '../../response';
|
||||
|
||||
/**
|
||||
* Issue client access token
|
||||
* @param oauth2 - OAuth2 instance
|
||||
* @param client - Client
|
||||
* @param wantScope - Requested scopes
|
||||
* @returns Access token
|
||||
|
@ -12,7 +12,6 @@ import type { OAuth2TokenResponse } from '../../response';
|
||||
|
||||
/**
|
||||
* Get a new access token from a refresh token. Scope change may not be requested.
|
||||
* @param oauth2 - OAuth2 instance
|
||||
* @param client - OAuth2 client
|
||||
* @param providedToken - Refresh token
|
||||
* @returns Access token
|
||||
|
@ -10,7 +10,6 @@ import { and, eq, sql } from 'drizzle-orm';
|
||||
import { OAuth2Clients } from './client';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { CryptoUtils } from '$lib/server/crypto-utils';
|
||||
import { AccessDenied } from '../error';
|
||||
|
||||
export type CodeChallengeMethod = 'plain' | 'S256';
|
||||
|
||||
@ -254,46 +253,6 @@ export class OAuth2AccessTokens {
|
||||
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 {
|
||||
|
@ -155,7 +155,7 @@
|
||||
<ViewColumn slot="side">
|
||||
<div>
|
||||
<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>
|
||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||
>{$t('account.avatar.change')}</Button
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { PUBLIC_URL } from '$env/static/public';
|
||||
import { ApiUtils } from '$lib/server/api-utils.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 { 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 { Users } from '$lib/server/users/index.js';
|
||||
|
||||
@ -17,7 +17,7 @@ export const GET = async ({ request, url, locals }) => {
|
||||
} else {
|
||||
// From OAuth2 bearer token
|
||||
try {
|
||||
const token = await OAuth2AccessTokens.getFromRequest(request, url);
|
||||
const token = await OAuth2BearerController.bearerFromRequest(request, url);
|
||||
if (token?.userId) {
|
||||
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
|
||||
user = await Users.getById(token.userId);
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { assets } from '$app/paths';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
@ -12,9 +12,8 @@
|
||||
</script>
|
||||
|
||||
<MainContainer>
|
||||
<h1>{$t('common.siteName')}</h1>
|
||||
|
||||
{#if data.error}
|
||||
<h1>{$t('common.siteName')}</h1>
|
||||
<ColumnView>
|
||||
<Alert type="error"
|
||||
>{$t('oauth2.authorize.errorPage')}<br /><br /><code
|
||||
@ -26,30 +25,60 @@
|
||||
{/if}
|
||||
|
||||
{#if data.client}
|
||||
<h2>{$t('oauth2.authorize.title')}</h2>
|
||||
{#if data.user}
|
||||
<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}
|
||||
<h1 class="title">{$t('common.siteName')}</h1>
|
||||
<h2 class="title">{$t('oauth2.authorize.title')}</h2>
|
||||
|
||||
<div class="user-client-wrapper">
|
||||
{#if data.user}
|
||||
<div class="card">
|
||||
<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 class="scope">
|
||||
{#if data.client.allowedScopes?.length}
|
||||
<h3>{$t('oauth2.authorize.allowed')}</h3>
|
||||
<div class="scope-list scope-list-allowed">
|
||||
{#each data.client.allowedScopes as scope}
|
||||
{$t(`oauth2.authorize.scope.${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}
|
||||
{$t(`oauth2.authorize.scope.${scope}`)}
|
||||
<span class="scope-list-item">{$t(`oauth2.authorize.scope.${scope}`)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<form action="" method="POST">
|
||||
<input type="hidden" value="1" name="decision" />
|
||||
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
|
||||
@ -59,5 +88,104 @@
|
||||
<input type="hidden" value="0" name="decision" />
|
||||
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
Loading…
Reference in New Issue
Block a user