OAuth2 authorization view

This commit is contained in:
Evert Prants 2022-03-16 20:37:50 +02:00
parent 5fa307b5b7
commit dc7f4215af
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
24 changed files with 432 additions and 45 deletions

78
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
@ -1646,6 +1647,19 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-2.0.1.tgz",
"integrity": "sha512-ginW73rmOjBN27USuGidetEoa8VSGzxW3kEuCquEd5mETEtBfgIm4901b9tuLDnsczttE01imCHZ53J7+AuLJg==",
"dependencies": {
"md5": "^2.2.1"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0",
"reflect-metadata": "^0.1.13"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3199,6 +3213,14 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
"engines": {
"node": "*"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -3629,6 +3651,14 @@
"node": ">= 8"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"engines": {
"node": "*"
}
},
"node_modules/cssom": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
@ -5400,6 +5430,11 @@
"node": ">=8"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/is-core-module": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
@ -6836,6 +6871,16 @@
"tmpl": "1.0.5"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -11313,6 +11358,14 @@
"tslib": "2.3.1"
}
},
"@nestjs/throttler": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-2.0.1.tgz",
"integrity": "sha512-ginW73rmOjBN27USuGidetEoa8VSGzxW3kEuCquEd5mETEtBfgIm4901b9tuLDnsczttE01imCHZ53J7+AuLJg==",
"requires": {
"md5": "^2.2.1"
}
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -12575,6 +12628,11 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc="
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -12921,6 +12979,11 @@
"which": "^2.0.1"
}
},
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs="
},
"cssom": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
@ -14254,6 +14317,11 @@
"binary-extensions": "^2.0.0"
}
},
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"is-core-module": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
@ -15362,6 +15430,16 @@
"tmpl": "1.0.5"
}
},
"md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"requires": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",

View File

@ -27,6 +27,7 @@
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",

View File

@ -1,4 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CSRFMiddleware } from './middleware/csrf.middleware';
@ -18,6 +19,10 @@ import { UtilityModule } from './modules/utility/utility.module';
@Module({
imports: [
ThrottlerModule.forRoot({
ttl: 10,
limit: 10,
}),
ConfigurationModule,
UtilityModule,
DatabaseModule,

View File

@ -9,7 +9,10 @@ import { NextFunction, Request, Response } from 'express';
export class AuthMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
if (!req.session.user) {
if (req.header('content-type')?.includes('application/json')) {
if (
req.header('content-type')?.includes('application/json') ||
req.header('accept')?.includes('application/json')
) {
throw new UnauthorizedException('Unauthorized');
}

View File

@ -11,7 +11,9 @@ export class UserMiddleware implements NestMiddleware {
// TODO: Cache user requests
// Might not be a big deal though, there is no expected volume in visitors
// TODO: check for bans
const userObj = await this.userService.getByUUID(req.session.user);
const userObj = await this.userService.getByUUID(req.session.user, [
'picture',
]);
if (userObj && userObj.activated) {
req.user = userObj;
} else {

View File

@ -8,7 +8,7 @@ import { OAuth2Service } from '../oauth2.service';
export class AccessTokenAdapter implements OAuth2AccessTokenAdapter {
constructor(private _service: OAuth2Service) {}
public ttl = 3600;
public ttl = 604800;
getToken(token: OAuth2AccessToken): string {
return token.token;

View File

@ -1,10 +1,23 @@
import { Controller, Get, Next, Post, Req, Res } from '@nestjs/common';
import { OAuth2AccessToken } from '@icynet/oauth2-provider';
import {
Controller,
Get,
Next,
NotFoundException,
Post,
Req,
Res,
} from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { ConfigurationService } from 'src/modules/config/config.service';
import { OAuth2Service } from './oauth2.service';
@Controller('oauth2')
export class OAuth2Controller {
constructor(private _service: OAuth2Service) {}
constructor(
private _service: OAuth2Service,
private _config: ConfigurationService,
) {}
// These requests are just passed straight on to the provider controller
@ -43,4 +56,45 @@ export class OAuth2Controller {
): void {
return this._service.oauth.controller.introspection(req, res, next);
}
// User information endpoint
// TODO: Move to API
@Get('user')
public async userInfo(
@Res({ passthrough: true }) res: Response,
): Promise<Record<string, any>> {
const token = res.locals.accessToken as OAuth2AccessToken;
const user = await this._service.userService.getById(
token.user_id as number,
['picture'],
);
if (!user) {
throw new NotFoundException('No such user');
}
const userData: Record<string, any> = {
id: user.id,
uuid: user.uuid,
username: user.username,
display_name: user.display_name,
};
if (token.scope.includes('email') || token.scope.includes('user:email')) {
userData.email = user.email;
}
if (
(token.scope.includes('image') || token.scope.includes('user:image')) &&
user.picture
) {
userData.image = `${this._config.get('app.base_url')}/uploads/${
user.picture.file
}`;
userData.image_file = user.picture.file;
}
return userData;
}
}

View File

@ -18,6 +18,7 @@ export class OAuth2Module implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(this._service.oauth.express()).forRoutes('oauth2/*');
consumer.apply(this._service.oauth.bearer).forRoutes('oauth2/user');
consumer.apply(AuthMiddleware).forRoutes('oauth2/authorize');
}
}

View File

@ -10,6 +10,14 @@ import { CodeAdapter } from './adapter/code.adapter';
import { RefreshTokenAdapter } from './adapter/refresh-token.adapter';
import { UserAdapter } from './adapter/user.adapter';
const SCOPE_DESCRIPTION: Record<string, string> = {
email: 'Email address',
image: 'Profile picture',
};
const ALWAYS_AVAILABLE = ['Username and display name'];
const ALWAYS_UNAVAILABLE = ['Password and other account settings'];
@Injectable()
export class OAuth2Service {
private _oauthAdapter: OAuth2AdapterModel = {
@ -20,10 +28,31 @@ export class OAuth2Service {
code: new CodeAdapter(this),
};
public oauth = new OAuth2Provider(this._oauthAdapter, async (req, res) => {
res.render('authorize');
public oauth = new OAuth2Provider(
this._oauthAdapter,
async (req, res, client, scope, user, redirectUri) => {
const fullClient = await this.clientService.getById(client.id as string);
const allowedScopes = [...ALWAYS_AVAILABLE];
const disallowedScopes = [...ALWAYS_UNAVAILABLE];
Object.keys(SCOPE_DESCRIPTION).forEach((item) => {
if (scope.includes(item)) {
allowedScopes.push(SCOPE_DESCRIPTION[item]);
} else {
disallowedScopes.push(SCOPE_DESCRIPTION[item]);
}
});
res.render('authorize', {
csrf: req.session.csrf,
user: req.user,
client: fullClient,
allowedScopes,
disallowedScopes,
});
},
);
constructor(
public token: TokenService,
public userService: UserService,
@ -36,6 +65,10 @@ export class OAuth2Service {
}
public splitScope(scope: string): string[] {
if (!scope) {
return [];
}
return scope.includes(',')
? scope.split(',').map((item) => item.trim())
: scope.split(' ');

View File

@ -9,6 +9,7 @@ import {
Res,
Session,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { SessionData } from 'express-session';
import { UserService } from 'src/modules/objects/user/user.service';
@ -32,6 +33,7 @@ export class RegisterController {
}
@Post()
@Throttle(3, 10)
public async registerRequest(
@Req() req: Request,
@Res() res: Response,

View File

@ -6,7 +6,7 @@ import { FormUtilityService } from 'src/modules/utility/services/form-utility.se
import { QRCodeService } from 'src/modules/utility/services/qr-code.service';
import { TokenService } from 'src/modules/utility/services/token.service';
@Controller('/two-factor')
@Controller('/account/two-factor')
export class TwoFactorController {
constructor(
private totp: UserTOTPService,
@ -15,7 +15,7 @@ export class TwoFactorController {
private form: FormUtilityService,
) {}
@Get()
@Get('activate')
public async twoFAStatus(
@Session() session: SessionData,
@Req() req: Request,
@ -52,7 +52,7 @@ export class TwoFactorController {
res.redirect('/');
}
@Post()
@Post('activate')
public async twoFAActivate(
@Session() session: SessionData,
@Body() body: { code: string },

View File

@ -11,7 +11,7 @@ import { TwoFactorController } from './two-factor.controller';
})
export class TwoFactorModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware).forRoutes('two-factor');
consumer.apply(AuthMiddleware).forRoutes('/account/two-factor/activate');
consumer.apply(FlashMiddleware).forRoutes('*');
}
}

View File

@ -2,6 +2,7 @@ import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@ -53,10 +54,10 @@ export class OAuth2Client {
})
public updated_at: Date;
@OneToOne(() => Upload, { nullable: true, onDelete: 'SET NULL' })
@ManyToOne(() => Upload, { nullable: true, onDelete: 'SET NULL' })
public picture: Upload;
@OneToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
public owner: User;
@OneToMany(() => OAuth2ClientURL, (url) => url.client)

View File

@ -22,9 +22,15 @@ export class OAuth2ClientService {
let client: OAuth2Client;
if (typeof id === 'string') {
client = await this.clientRepository.findOne({ client_id: id });
client = await this.clientRepository.findOne(
{ client_id: id },
{ relations: ['urls', 'picture'] },
);
} else {
client = await this.clientRepository.findOne({ id });
client = await this.clientRepository.findOne(
{ id },
{ relations: ['urls', 'picture'] },
);
}
return client;
@ -34,14 +40,23 @@ export class OAuth2ClientService {
id: string,
type?: OAuth2ClientURLType,
): Promise<OAuth2ClientURL[]> {
return this.clientUrlRepository.find({ client: { client_id: id }, type });
return this.clientUrlRepository.find({
where: {
client: { client_id: id },
type,
},
relations: ['client'],
});
}
public async checkRedirectURI(id: string, url: string): Promise<boolean> {
return !!(await this.clientUrlRepository.findOne({
return !!(await this.clientUrlRepository.findOne(
{
client: { client_id: id },
url,
type: OAuth2ClientURLType.REDIRECT_URI,
}));
},
{ relations: ['client'] },
));
}
}

View File

@ -36,10 +36,13 @@ export class OAuth2TokenService {
token: string,
type: OAuth2TokenType,
): Promise<OAuth2Token> {
return this.tokenRepository.findOne({
return this.tokenRepository.findOne(
{
token,
type,
});
},
{ relations: ['client', 'user'] },
);
}
public async fetchByUserIdClientId(
@ -57,6 +60,7 @@ export class OAuth2TokenService {
},
type,
},
relations: ['client', 'user'],
});
}

View File

@ -2,6 +2,7 @@ import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
@ -47,7 +48,7 @@ export class User {
})
public updated_at: Date;
@OneToOne(() => Upload, {
@ManyToOne(() => Upload, {
nullable: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',

View File

@ -21,36 +21,42 @@ export class UserService {
private config: ConfigurationService,
) {}
public async getById(id: number): Promise<User> {
return this.userRepository.findOne({ id });
public async getById(id: number, relations?: string[]): Promise<User> {
return this.userRepository.findOne({ id }, { relations });
}
public async getByUUID(uuid: string): Promise<User> {
return this.userRepository.findOne({ uuid });
public async getByUUID(uuid: string, relations?: string[]): Promise<User> {
return this.userRepository.findOne({ uuid }, { relations });
}
public async getByEmail(email: string): Promise<User> {
return this.userRepository.findOne({ email });
public async getByEmail(email: string, relations?: string[]): Promise<User> {
return this.userRepository.findOne({ email }, { relations });
}
public async getByUsername(username: string): Promise<User> {
return this.userRepository.findOne({ username });
public async getByUsername(
username: string,
relations?: string[],
): Promise<User> {
return this.userRepository.findOne({ username }, { relations });
}
public async get(input: string | number): Promise<User> {
public async get(
input: string | number,
relations?: string[],
): Promise<User> {
if (typeof input === 'number') {
return this.getById(input);
return this.getById(input, relations);
}
if (input.includes('@')) {
return this.getByEmail(input);
return this.getByEmail(input, relations);
}
if (input.length === 36 && input.includes('-')) {
return this.getByUUID(input);
return this.getByUUID(input, relations);
}
return this.getByUsername(input);
return this.getByUsername(input, relations);
}
public async updateUser(user: User): Promise<User> {

View File

@ -14,6 +14,10 @@ export class TokenService {
return crypto.randomBytes(length).toString('hex').slice(0, length);
}
public generateSecret(): string {
return crypto.randomBytes(256 / 8).toString('hex');
}
public createUUID(): string {
return v4();
}

102
src/scss/_authorize.scss Normal file
View File

@ -0,0 +1,102 @@
.authorize {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
background-color: #005b74;
margin: 2rem -4rem;
padding: 2rem 1rem;
box-shadow: inset 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
@include break-on(xs, down) {
margin: 1rem -1rem;
padding: 1rem;
}
&__user,
&__client {
display: flex;
flex-direction: row;
&-image {
width: 120px;
height: 120px;
flex-shrink: 0;
background-color: #b5b5b5;
}
&-content {
display: flex;
flex-direction: column;
padding: 0 8px;
}
&-title {
font-size: 1.5rem;
}
}
&__center {
background-image: 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='currentColor' d='M10,4H14V13L17.5,9.5L19.92,11.92L12,19.84L4.08,11.92L6.5,9.5L10,13V4Z' /%3E%3C/svg%3E");
width: 80px;
height: 80px;
opacity: 0.4;
margin: 2rem;
flex-shrink: 0;
}
&__user {
&-user {
color: #b5b5b5;
}
}
&__client {
min-height: 120px;
&-urls {
margin-top: auto;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
&-url {
margin-top: 2px;
}
&-description {
margin-bottom: 4px;
}
}
}
.scopes {
display: flex;
flex-direction: column;
max-width: 400px;
margin: auto;
.scopes__scope {
display: flex;
flex-direction: row;
align-items: center;
font-weight: bold;
&::before {
content: '';
display: block;
width: 32px;
height: 32px;
margin: 4px;
}
&--allowed::before {
background-image: 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='%2300f000' d='M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z' /%3E%3C/svg%3E");
}
&--disallowed::before {
background-image: 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");
}
}
}

View File

@ -10,7 +10,7 @@
max-width: 800px;
background-color: #2e6b81;
color: #fff;
margin: 2rem auto 0;
margin: 2rem auto;
padding: 4rem;
position: relative;
@ -26,12 +26,16 @@
background-color: #042b3a;
overflow: hidden;
max-width: 600px;
margin: 0 auto;
margin: 0 auto 2rem auto;
padding: 16px;
text-align: center;
box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
}
&--no-margin {
margin-bottom: 0;
}
@include break-on(xs, down) {
padding: 1rem;
}

View File

@ -16,3 +16,6 @@
.flex-row {
flex-direction: row;
}
.text-center {
text-align: center;
}

View File

@ -4,6 +4,7 @@
@import 'button';
@import 'flex';
@import 'alert';
@import 'authorize';
*,
*::before,
@ -23,3 +24,21 @@ body {
background-color: #314550;
text-shadow: black 1px 1px 2px;
}
a {
color: #fff;
&:hover {
text-decoration: none;
}
&[target='_blank']::after {
content: '';
background-image: 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='%23ffffff' d='M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z' /%3E%3C/svg%3E");
width: 1rem;
height: 1rem;
display: inline-block;
margin-left: 4px;
vertical-align: top;
}
}

View File

@ -1,7 +1,56 @@
extends partials/layout.pug
block title
|Icy Network | Authorize
|Icy Network | Authorize application
block body
h1 Authorize
include partials/logo.pug
div.container
div.center-box
h1 Authorize application
.authorize
.authorize__user
.authorize__user-image
.authorize__user-content
span.authorize__user-title #{user.display_name}
span.authorize__user-user @#{user.username}
.authorize__center
.authorize__client
.authorize__client-image
.authorize__client-content
span.authorize__client-title #{client.title}
span.authorize__client-description #{client.description}
.authorize__client-urls
each url in client.urls
if url.type == 'website'
a.authorize__client-url(href=url.url, target="_blank", rel="nofollow") Visit website
if url.type == 'privacy'
a.authorize__client-url(href=url.url, target="_blank", rel="nofollow") Privacy Policy
if url.type == 'terms'
a.authorize__client-url(href=url.url, target="_blank", rel="nofollow") Terms of Service
h2.text-center This application will have access to..
.scopes
each allowed in allowedScopes
span.scopes__scope.scopes__scope--allowed #{allowed}
h2.text-center This application will not have access to..
.scopes
each allowed in disallowedScopes
span.scopes__scope.scopes__scope--disallowed #{allowed}
form(method="POST", action="")
div.form-container
input(type="hidden", name="csrf", value=csrf)
input(type="hidden", name="decision", value="1")
button.btn.btn-primary(type="submit") Authorize
form(method="POST", action="")
div.form-container
input(type="hidden", name="csrf", value=csrf)
input(type="hidden", name="decision", value="0")
button.btn.btn-link(type="submit") Reject

View File

@ -6,7 +6,7 @@ block title
block body
include ../partials/logo.pug
div.container
div.center-box
div.center-box.center-box--no-margin
h1 Log in
if message.text
if message.error