forked from FoundKeyGang/FoundKey
implement OAuth PKCE
This implements Proof Key for Code Exchange a.k.a. RFC 7636.
This commit is contained in:
parent
15b3ab6d13
commit
5291f29581
5 changed files with 67 additions and 0 deletions
12
packages/backend/migration/1667738304733-pkce.js
Normal file
12
packages/backend/migration/1667738304733-pkce.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export class pkce1667738304733 {
|
||||||
|
name = 'pkce1667738304733'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" ADD "pkceChallenge" text`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "auth_session"."pkceChallenge" IS 'PKCE code_challenge value, if provided (OAuth only)'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "auth_session" DROP COLUMN "pkceChallenge"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,4 +40,10 @@ export class AuthSession {
|
||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public app: App | null;
|
public app: App | null;
|
||||||
|
|
||||||
|
@Column('text', {
|
||||||
|
nullable: true,
|
||||||
|
comment: 'PKCE code_challenge value, if provided (OAuth only)',
|
||||||
|
})
|
||||||
|
pkceChallenge: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ export async function oauth(ctx: Koa.Context): void {
|
||||||
grant_type,
|
grant_type,
|
||||||
code,
|
code,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
} = ctx.request.body;
|
} = ctx.request.body;
|
||||||
|
|
||||||
// check if any of the parameters are null or empty string
|
// check if any of the parameters are null or empty string
|
||||||
|
@ -79,6 +80,34 @@ export async function oauth(ctx: Koa.Context): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check PKCE challenge, if provided before
|
||||||
|
if (session.pkceChallenge) {
|
||||||
|
// Also checking the client's homework, the RFC says:
|
||||||
|
//> minimum length of 43 characters and a maximum length of 128 characters
|
||||||
|
if (!code_verifier || code_verifier.length < 43 || code_verifier.length > 128) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'invalid or missing PKCE code_verifier',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// verify that (from RFC 7636):
|
||||||
|
//> BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
hash.update(code_verifier);
|
||||||
|
|
||||||
|
if (hash.digest('base64url') !== code_challenge) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'invalid PKCE code_verifier',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// check redirect URI
|
// check redirect URI
|
||||||
if (!compareUrl(app.callbackUrl, redirect_uri)) {
|
if (!compareUrl(app.callbackUrl, redirect_uri)) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
|
|
|
@ -52,6 +52,10 @@ export const paramDef = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
},
|
},
|
||||||
|
pkceChallenge: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['clientId']
|
required: ['clientId']
|
||||||
}, {
|
}, {
|
||||||
|
@ -93,6 +97,7 @@ export default define(meta, paramDef, async (ps) => {
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
token,
|
token,
|
||||||
|
pkceChallenge: ps.pkceChallenge,
|
||||||
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
|
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -64,6 +64,20 @@ onMounted(async () => {
|
||||||
if (params.get('response_type') === 'code') {
|
if (params.get('response_type') === 'code') {
|
||||||
// OAuth request detected!
|
// OAuth request detected!
|
||||||
|
|
||||||
|
// if PKCE is used, check that it is a supported method
|
||||||
|
// the default value for code_challenge_method if not supplied is 'plain', which is not supported.
|
||||||
|
if (params.has('code_challenge') && params.get('code_challenge_method') !== 'S256') {
|
||||||
|
if (params.has('redirect_uri')) {
|
||||||
|
location.href = appendQuery(params.get('redirect_uri'), query({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'unsupported code_challenge_method, only "S256" is supported',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
state = 'oauth-error';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// as a kind of hack, we first have to start the session for the OAuth client
|
// as a kind of hack, we first have to start the session for the OAuth client
|
||||||
const clientId = params.get('client_id');
|
const clientId = params.get('client_id');
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
|
@ -75,6 +89,7 @@ onMounted(async () => {
|
||||||
clientId,
|
clientId,
|
||||||
// make the server check the redirect, if provided
|
// make the server check the redirect, if provided
|
||||||
callbackUrl: params.get('redirect_uri') ?? undefined,
|
callbackUrl: params.get('redirect_uri') ?? undefined,
|
||||||
|
pkceChallenge: params.get('code_challenge') ?? undefined,
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
const response = {
|
const response = {
|
||||||
error: 'server_error',
|
error: 'server_error',
|
||||||
|
|
Loading…
Reference in a new issue