Skip to content

Commit

Permalink
Refactoring OAuth2 service
Browse files Browse the repository at this point in the history
This became a bit bigger than expected, but:

* Refactors the OAuth2 service to have more consistent function
  signatures.
* Adds token statistics to the oauth2 homepage.
* Stores the 'grant_type' and whether a 'secret' was used in the tokens
  table.
* We're now storing 'scope' for every token. This OAuth2 feature wasn't
  really used by this server, but this sets up the first steps for this.
* Fixes a bug related to generating principal uris in the introspection
  endpoints.
* Has more explicit support for the 2 a12nserver-specific oauth2 flows:
  "developer tokens" and "one-time-tokens".

Other side-effects of this PR:

* A few step furthers in #405
* Some progress towards OpenID Connect support (scopes are important for
  this).
  • Loading branch information
evert committed Sep 12, 2022
1 parent 9a1ab52 commit 530d5c1
Show file tree
Hide file tree
Showing 18 changed files with 462 additions and 231 deletions.
14 changes: 12 additions & 2 deletions src/db-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type Oauth2CodesRecord = {
code: string;
user_id: number;
code_challenge: string | null;
code_challenge_method: 'plain' | 'S256' | null | null;
code_challenge_method: 'plain' | 'S256' | null;
created: number;
browser_session_id: string | null;
}
Expand All @@ -69,8 +69,18 @@ export type Oauth2TokensRecord = {
user_id: number;
access_token_expires: number;
refresh_token_expires: number;
created: number;
created_at: number;
browser_session_id: string | null;

/**
* 1=implicit, 2=client_credentials, 3=password, 4=authorization_code, 5=authorization_code with secret
*/
grant_type: number | null;

/**
* OAuth2 scopes, comma separated
*/
scope: string | null;
}

export type PrincipalsRecord = {
Expand Down
24 changes: 19 additions & 5 deletions src/home/formats/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,39 @@ import { App, User } from '../../principal/types';
import { getSetting } from '../../server-settings';
import { ServerStats } from '../../types';


function statsBlock(kind: string, count: number): string {

return `
<div class="statsBlock">
<div class="counter">
<span class="num">${count}</span>
<span class="num">${humanNum(count)}</span>
<span class="kind">${kind}${count!=1?'s':''}</span>
</div>
<div class="actions"><a href="/${kind}" rel="${kind}-collection">Manage</a></div>
${kind!=='token' ? `<div class="actions"><a href="/${kind}" rel="${kind}-collection">Manage</a></div>`: ''}
</div>
`;

}

function humanNum(i: number): string {

switch(true) {
case i >= 10_000_000 :
return (i / 1_000_000).toFixed(0) + 'M';
case i >= 1_000_000 :
return (i / 1_000_000).toFixed(1) + 'M';
case i >= 1_0000 :
return (i / 1_000).toFixed(0) + 'K';
case i >= 1_000:
return (i / 1_000).toFixed(1) + 'K';
default :
return i.toString();
}

}

export default (version: string, authenticatedUser: User | App, isAdmin: boolean, serverStats: ServerStats) => {

Expand All @@ -28,6 +43,7 @@ export default (version: string, authenticatedUser: User | App, isAdmin: boolean
${statsBlock('user', serverStats.user)}
${statsBlock('app', serverStats.app)}
${statsBlock('group', serverStats.group)}
${statsBlock('token', serverStats.tokensIssued)}
${statsBlock('privilege', serverStats.privileges)}
</div>
Expand Down Expand Up @@ -63,5 +79,3 @@ _Version ${version}_
`;

};


4 changes: 3 additions & 1 deletion src/home/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { ServerStats } from '../types';
import { getPrincipalStats } from '../principal/service';
import { findPrivileges } from '../privilege/service';
import { lastTokenId } from '../oauth2/service';

export async function getServerStats(): Promise<ServerStats> {

return {
...await getPrincipalStats(),
privileges: (await findPrivileges()).length
privileges: (await findPrivileges()).length,
tokensIssued: (await lastTokenId()),
};

}
2 changes: 1 addition & 1 deletion src/introspect/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class IntrospectionController extends Controller {

}
if (foundToken) {
const privileges = await privilegeService.getPrivilegesForPrincipal(foundToken.user);
const privileges = await privilegeService.getPrivilegesForPrincipal(foundToken.principal);

switch (foundTokenType!) {

Expand Down
8 changes: 4 additions & 4 deletions src/introspect/formats/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ export function accessToken(token: OAuth2Token, privileges: PrivilegeMap) {
scope: Object.values(privileges).join(' '),
privileges: privileges,
client_id: token.clientId,
username: token.user.nickname,
username: token.principal.nickname,
token_type: 'bearer',
exp: token.accessTokenExpires,
_links: {
'authenticated-as': {
href: url.resolve(process.env.PUBLIC_URI!, '/user/' + token.user.id),
href: url.resolve(process.env.PUBLIC_URI!, token.principal.href),
}
}
};
Expand All @@ -28,11 +28,11 @@ export function refreshToken(token: OAuth2Token, privileges: PrivilegeMap) {
scope: Object.values(privileges).join(' '),
privileges: privileges,
client_id: token.clientId,
username: token.user.nickname,
username: token.principal.nickname,
token_type: 'refresh_token',
_links: {
'authenticated-as': {
href: url.resolve(process.env.PUBLIC_URI!, '/user/' + token.user.id),
href: url.resolve(process.env.PUBLIC_URI!, token.principal.href),
}
}
};
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default function(): Middleware {
}
}
// We are logged in!
ctx.auth = new AuthHelper(token.user);
ctx.auth = new AuthHelper(token.principal);

return next();

Expand Down
23 changes: 23 additions & 0 deletions src/migrations/20220911180000_token_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('oauth2_tokens', table => {
table
.renameColumn('created', 'created_at');
table
.tinyint('grant_type')
.unsigned()
.nullable()
.comment('1=implicit, 2=client_credentials, 3=password, 4=authorization_code, 5=authorization_code with secret,6=one-time-token');
table
.string('scope', 1024)
.nullable()
.comment('OAuth2 scopes, space separated');
});
}

export async function down(knex: Knex): Promise<void> {

throw new Error('This migration cannot be undone');

}
12 changes: 7 additions & 5 deletions src/oauth2-client/controller/collection.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as bcrypt from 'bcrypt';
import Controller from '@curveball/controller';
import { Context } from '@curveball/core';
import * as privilegeService from '../../privilege/service';
import * as hal from '../formats/hal';
import { Forbidden, UnprocessableEntity } from '@curveball/http-errors';
import { findByApp, create } from '../service';

import * as hal from '../formats/hal';
import * as principalService from '../../principal/service';
import { GrantType, OAuth2Client } from '../types';
import * as bcrypt from 'bcrypt';
import * as privilegeService from '../../privilege/service';
import { GrantType } from '../../types';
import { OAuth2Client } from '../types';
import { findByApp, create } from '../service';
import { generatePublicId, generateSecretToken } from '../../crypto';

class ClientCollectionController extends Controller {
Expand Down
2 changes: 1 addition & 1 deletion src/oauth2-client/controller/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Forbidden, NotFound, UnprocessableEntity } from '@curveball/http-errors
import * as principalService from '../../principal/service';
import { findByClientId, edit } from '../service';
import * as oauth2Service from '../../oauth2/service';
import { GrantType } from '../types';
import { GrantType } from '../../types';

class EditClientController extends Controller {

Expand Down
12 changes: 7 additions & 5 deletions src/oauth2-client/service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { OAuth2Client, GrantType } from './types';
import * as bcrypt from 'bcrypt';
import * as principalService from '../principal/service';
import db, { insertAndGetId } from '../database';
import { Context } from '@curveball/core';
import { NotFound, Unauthorized, Conflict } from '@curveball/http-errors';
import { Oauth2ClientsRecord } from 'knex/types/tables';
import { wrapError, UniqueViolationError } from 'db-errors';

import { OAuth2Client } from './types';
import * as principalService from '../principal/service';
import db, { insertAndGetId } from '../database';
import { InvalidRequest } from '../oauth2/errors';
import parseBasicAuth from './parse-basic-auth';
import { App } from '../principal/types';
import { Oauth2ClientsRecord } from 'knex/types/tables';
import { wrapError, UniqueViolationError } from 'db-errors';
import { GrantType } from '../types';

export async function findByClientId(clientId: string): Promise<OAuth2Client> {

Expand Down
3 changes: 1 addition & 2 deletions src/oauth2-client/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { App } from '../principal/types';

export type GrantType = 'refresh_token' | 'client_credentials' | 'password' | 'implicit' | 'authorization_code';
import { GrantType } from '../types';

/**
* The OAuth2 client refers to a single (programmatic) client, accessing
Expand Down
12 changes: 7 additions & 5 deletions src/oauth2/controller/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ class AuthorizeController extends Controller {

async tokenRedirect(ctx: Context, oauth2Client: OAuth2Client, redirectUri: string, state: string|undefined) {

const token = await oauth2Service.generateTokenForUser(
oauth2Client,
ctx.session.user
);
const token = await oauth2Service.generateTokenImplicit({
client: oauth2Client,
scope: ctx.params.scope?.split(' ') ?? null,
principal: ctx.session.user,
browserSessionId: ctx.sessionId!,
});

ctx.status = 302;
ctx.response.headers.set('Cache-Control', 'no-cache');
Expand All @@ -129,7 +131,7 @@ class AuthorizeController extends Controller {
codeChallengeMethod: 'S256' | 'plain' | undefined
) {

const code = await oauth2Service.generateCodeForUser(
const code = await oauth2Service.generateAuthorizationCode(
oauth2Client,
ctx.session.user,
codeChallenge,
Expand Down
52 changes: 30 additions & 22 deletions src/oauth2/controller/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,16 @@ class TokenController extends Controller {

let oauth2Client: OAuth2Client;

switch (grantType) {

case 'authorization_code' :
case 'refresh_token' :
if (ctx.request.headers.has('Authorization')) {
oauth2Client = await getOAuth2ClientFromBasicAuth(ctx);
} else {
oauth2Client = await getOAuth2ClientFromBody(ctx);
}
break;
default :
oauth2Client = await getOAuth2ClientFromBasicAuth(ctx);
break;

let secretUsed: boolean;
if (ctx.request.headers.has('Authorization')) {
oauth2Client = await getOAuth2ClientFromBasicAuth(ctx);
secretUsed = true;
} else {
if (!['authorization_code', 'refresh_token'].includes(grantType)) {
throw new InvalidRequest('A secret must be specified when using the client_credentials grant');
}
oauth2Client = await getOAuth2ClientFromBody(ctx);
secretUsed = false;
}

if (!oauth2Client.allowedGrantTypes.includes(grantType)) {
Expand All @@ -50,7 +46,7 @@ class TokenController extends Controller {

switch (grantType) {
case 'authorization_code' :
return this.authorizationCode(oauth2Client, ctx);
return this.authorizationCode(oauth2Client, ctx, secretUsed);
case 'client_credentials' :
return this.clientCredentials(oauth2Client, ctx);
case 'password' :
Expand All @@ -63,7 +59,10 @@ class TokenController extends Controller {

async clientCredentials(oauth2Client: OAuth2Client, ctx: Context) {

const token = await oauth2Service.generateTokenForClient(oauth2Client);
const token = await oauth2Service.generateTokenClientCredentials({
client: oauth2Client,
scope: ctx.request.body.scope?.split(' ') ?? null,
});

ctx.response.type = 'application/json';
ctx.response.body = {
Expand All @@ -74,7 +73,7 @@ class TokenController extends Controller {

}

async authorizationCode(oauth2Client: OAuth2Client, ctx: Context<any>) {
async authorizationCode(oauth2Client: OAuth2Client, ctx: Context<any>, secretUsed: boolean) {

if (!ctx.request.body.code) {
throw new InvalidRequest('The "code" property is required');
Expand All @@ -86,7 +85,13 @@ class TokenController extends Controller {
log(EventType.oauth2BadRedirect, ctx);
throw new InvalidRequest('This value for "redirect_uri" is not recognized.');
}
const token = await oauth2Service.generateTokenFromCode(oauth2Client, ctx.request.body.code, ctx.request.body.code_verifier);
const token = await oauth2Service.generateTokenAuthorizationCode({
client: oauth2Client,
code: ctx.request.body.code,
codeVerifier: ctx.request.body.code_verifier,
scope: ctx.request.body.scope?.split(' ') ?? null,
secretUsed,
});

ctx.response.type = 'application/json';
ctx.response.body = {
Expand Down Expand Up @@ -122,10 +127,13 @@ class TokenController extends Controller {

log(EventType.loginSuccess, ctx);

const token = await oauth2Service.generateTokenForUser(
oauth2Client,
user
);
const scope = ctx.request.body.scope?.split(' ') ?? null;

const token = await oauth2Service.generateTokenPassword({
client: oauth2Client,
principal: user,
scope
});

ctx.response.body = {
access_token: token.accessToken,
Expand Down
4 changes: 3 additions & 1 deletion src/oauth2/controller/user-access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ class UserAccessTokenController extends Controller {
if (user.type !== 'user') {
throw new BadRequest('This API can only be used for principals of type \'user\'');
}
const token = await oauth2Service.generateTokenForUserNoClient(user);
const token = await oauth2Service.generateTokenDeveloperToken({
principal: user,
});

ctx.response.body = {
access_token: token.accessToken,
Expand Down
Loading

0 comments on commit 530d5c1

Please sign in to comment.