Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add origin restriction to session token #46

Merged
merged 38 commits into from
Apr 26, 2023
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b0869a8
basic origin restriction
KirillDogadin-std Apr 13, 2023
09e5402
comma separation logic for initialization
KirillDogadin-std Apr 13, 2023
46037cd
support multiple allowed origins during validation
KirillDogadin-std Apr 13, 2023
e6202a1
add tests
KirillDogadin-std Apr 13, 2023
a8e1f8d
use wildcard
KirillDogadin-std Apr 13, 2023
f80b5f0
lint
KirillDogadin-std Apr 13, 2023
cf077ce
lint
KirillDogadin-std Apr 13, 2023
e382f45
default value on frontend
KirillDogadin-std Apr 13, 2023
af418f2
fix test
KirillDogadin-std Apr 13, 2023
5ce4fdf
improve tests
KirillDogadin-std Apr 13, 2023
568db2f
lint
KirillDogadin-std Apr 13, 2023
962e335
add test
KirillDogadin-std Apr 13, 2023
c5a490e
move logic to helper
KirillDogadin-std Apr 13, 2023
5757242
move throwing into helper
KirillDogadin-std Apr 13, 2023
6908ff3
rename
KirillDogadin-std Apr 13, 2023
c1818fa
lint
KirillDogadin-std Apr 13, 2023
3a62c76
table works
KirillDogadin-std Apr 13, 2023
ae91a76
more optimal list matcher
KirillDogadin-std Apr 13, 2023
6a9899d
trim instead of invalidation
KirillDogadin-std Apr 17, 2023
d3076b6
throw error in validator
KirillDogadin-std Apr 17, 2023
0ed5666
lint
KirillDogadin-std Apr 17, 2023
61b47e6
move logic
KirillDogadin-std Apr 17, 2023
ff633b7
return early
KirillDogadin-std Apr 17, 2023
c0cf518
rename
KirillDogadin-std Apr 17, 2023
552bc7b
origin header is in env
KirillDogadin-std Apr 17, 2023
f138154
lint
KirillDogadin-std Apr 17, 2023
64528b6
add the origin into the readme
KirillDogadin-std Apr 17, 2023
e571847
add env var to kube values
KirillDogadin-std Apr 17, 2023
ed489e5
fix frontend
KirillDogadin-std Apr 17, 2023
cb5205c
adjust the readme
KirillDogadin-std Apr 17, 2023
7c3ef05
rename func
KirillDogadin-std Apr 17, 2023
d9a0d58
rename and move things
KirillDogadin-std Apr 17, 2023
07d968f
rename the table col
KirillDogadin-std Apr 17, 2023
6fa26ec
add comment to prisma
KirillDogadin-std Apr 17, 2023
12a77a8
remove env from readme and kube values
KirillDogadin-std Apr 17, 2023
bceee56
record origin post factum
KirillDogadin-std Apr 17, 2023
eae6228
lint
KirillDogadin-std Apr 17, 2023
700832b
Merge branch 'main' into origin-restriction-session
KirillDogadin-std Apr 18, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Some environment variables are pre-configured for the development. You can copy
- `AUTH_SIGNUP_ENABLED` (optional, default: `false`): if signing up mutation is allowed (i.e. user creation via endpoint is enabled)
- `JWT_EXPIRATION_PERIOD` (optional, default: `'7d'`): how soon the signed jwt token will expire
- `DEBUG` (optional): if set, enables the different more explicit logging mode where debug levels are set to `debug` for the app's logger and `query` for db logger
- `OWN_ORIGIN` (optional, default: `'http://localhost:*'`): defines the origin where the api is located. The generated sign in and sign up tokens will be restricted to this value.

### Database

Expand Down
11 changes: 11 additions & 0 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"pino-pretty": "^10.0.0",
"vite-node": "^0.29.2",
"vitest": "^0.29.2",
"wildcard-match": "^5.1.2",
"zod": "^3.21.4"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ model Session {
revokedAt DateTime?
referenceTokenId String
isUserCreated Boolean @default(false)
allowedOrigins String // comma separated strings

creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade)

Expand Down
1 change: 1 addition & 0 deletions api/src/env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export const PORT = Number(process.env.PORT ?? '3000');
export const isDevelopment = process.env.NODE_ENV === 'development';
export const AUTH_SIGNUP_ENABLED = Boolean(process.env.AUTH_SIGNUP_ENABLED);
export const JWT_EXPIRATION_PERIOD: string = getJwtExpirationPeriod();
export const OWN_ORIGIN = process.env.OWN_ORIGIN || 'http://localhost:*';
4 changes: 4 additions & 0 deletions api/src/generated/nexus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ declare global {

export interface NexusGenInputs {
SessionCreate: { // input type
allowedOrigins: string; // String!
expiryDurationSeconds?: number | null; // Int
name: string; // String!
}
Expand Down Expand Up @@ -80,6 +81,7 @@ export interface NexusGenObjects {
Mutation: {};
Query: {};
Session: { // root type
allowedOrigins?: string | null; // String
createdAt: NexusGenScalars['GQLDateBase']; // GQLDateBase!
createdBy: string; // String!
id: string; // String!
Expand Down Expand Up @@ -138,6 +140,7 @@ export interface NexusGenFieldTypes {
sessions: Array<NexusGenRootTypes['Session'] | null> | null; // [Session]
}
Session: { // field return type
allowedOrigins: string | null; // String
createdAt: NexusGenScalars['GQLDateBase']; // GQLDateBase!
createdBy: string; // String!
id: string; // String!
Expand Down Expand Up @@ -186,6 +189,7 @@ export interface NexusGenFieldTypeNames {
sessions: 'Session'
}
Session: { // field return type name
allowedOrigins: 'String'
createdAt: 'GQLDateBase'
createdBy: 'String'
id: 'String'
Expand Down
2 changes: 2 additions & 0 deletions api/src/generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Query {
}

type Session {
allowedOrigins: String
createdAt: GQLDateBase!
createdBy: String!
id: String!
Expand All @@ -47,6 +48,7 @@ type Session {
}

input SessionCreate {
allowedOrigins: String!
expiryDurationSeconds: Int
name: String!
}
Expand Down
3 changes: 2 additions & 1 deletion api/src/graphql/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ export function createContext(params: CreateContextParams): Context {
const { req } = params;
const authorizationHeader = req.get('Authorization');
const token = authorizationHeader?.replace('Bearer ', '');
const origin = req.get('Origin');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@valiafetisov don't have prior xp on this one, googling did not yield me a definite answer.

Is relying on the origin header safe? can't it be forged? e.g. in tests i manage to put any origin i want and hence i imagine that this approach is not safe.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header can not be changed by the JavaScript running in the browser, but can be set in many other environments (like curl). It is sufficient for the issue

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related question to @DeusAvalon. Is it enough to just req.get('Origin') on the application level to get the actual origin (not frontend:3000) or should ingress be additionally configured to pass proper header to the application? Or does it come via something like x-origin?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im pretty sure req.get('Origin') on Application level should give the correct Origin (actual Ingress URL) as the Ingress does not strip/replace any headers in our current configuration.

Copy link
Contributor

@valiafetisov valiafetisov Apr 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, then what header contains internal frontend:3000? And what is proxy-add-original-uri-header for? (related issue discussing the subject)

If possible, I would ask kirill to enable staging deployments on this branch to validate that it indeed works, but as the repo wasn't transferred yet, we would need to test (and potentially fix) it after this PR is merged, via our fork

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm i see, yeah im not 100% sure either i would need to actually deploy a service and check which headers are being parsed to the service.

But the staging deployment could actually be done by me manually deploying it based on this branch. This way we can pre-validate if its actually working. If this would work for you i would organize this tmrw with @KirillDogadin-std together


return {
request: params,
prisma,
apolloLogger,
getSession: async () => prisma.session.getSessionByToken(token),
getSession: async () => prisma.session.getSessionByToken(origin, token),
};
}
81 changes: 81 additions & 0 deletions api/src/modules/Session/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { GraphQLError } from 'graphql';
import wildcard from 'wildcard-match';
import { token as tokenUtils } from '../../helpers';

function parseOriginMarkup(originParam: string): string {
if (originParam === '*') {
return '*';
}
const trimmedOriginParam = originParam.trim();
const origins = trimmedOriginParam.split(',').map((origin) => origin.trim());
origins.forEach((origin) => {
if (!origin.startsWith('http://') && !origin.startsWith('https://')) {
throw new GraphQLError("Origin must start with 'http://' or 'https://'", {
extensions: { code: 'INVALID_ORIGIN_PROTOCOL' },
});
}
});
return origins.join(',');
}

export function validateOriginAgainstAllowed(
allowedOrigins: string,
originReceived?: string,
) {
if (allowedOrigins === '*') {
return;
}
if (!originReceived) {
throw new GraphQLError('Origin not provided', {
extensions: { code: 'ORIGIN_HEADER_MISSING' },
});
}
const allowedOriginsSplit = allowedOrigins.split(',');
if (!wildcard(allowedOriginsSplit)(originReceived)) {
throw new GraphQLError('Access denied due to origin restriction', {
extensions: { code: 'ORIGIN_FORBIDDEN' },
});
}
}

async function newSession(
prisma: PrismaClient,
session: Prisma.SessionCreateInput,
) {
return prisma.session.create({
data: session,
});
}

export const generateTokenAndSession = async (
prisma: PrismaClient,
userId: string,
session: { expiryDurationSeconds?: number | null; name: string; allowedOrigins: string },
isUserCreated: boolean = false,
) => {
const createId = randomUUID();
const createdToken = tokenUtils.generate(createId, session.expiryDurationSeconds);
const expiryDate = tokenUtils.getExpiryDateFromToken(createdToken);
const formattedToken = tokenUtils.format(createdToken);
const parsedAllowedOrigins = parseOriginMarkup(session.allowedOrigins);
const createData = {
allowedOrigins: parsedAllowedOrigins,
name: session.name,
referenceExpiryDate: expiryDate,
id: createId,
referenceTokenId: formattedToken,
isUserCreated,
creator: {
connect: {
id: userId,
},
},
};
const createdSession = await newSession(prisma, createData);
return {
token: createdToken,
session: createdSession,
};
};
54 changes: 10 additions & 44 deletions api/src/modules/Session/model.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import type { PrismaClient } from '@prisma/client';
import { inputObjectType, objectType } from 'nexus/dist';
import { randomUUID } from 'crypto';
import { GraphQLError } from 'graphql';
import ms from 'ms';
import { token as tokenUtils } from '../../helpers';
import { JWT_EXPIRATION_PERIOD } from '../../env';
import { JWT_EXPIRATION_PERIOD, OWN_ORIGIN } from '../../env';
import { validateOriginAgainstAllowed, generateTokenAndSession } from './helpers';

export const Session = objectType({
name: 'Session',
Expand All @@ -17,6 +17,7 @@ export const Session = objectType({
t.nonNull.boolean('isUserCreated');
t.string('name');
t.date('revokedAt');
t.string('allowedOrigins');
},
});

Expand All @@ -25,6 +26,7 @@ export const SessionCreate = inputObjectType({
definition(t) {
t.int('expiryDurationSeconds');
t.nonNull.string('name');
t.nonNull.string('allowedOrigins');
},
});

Expand All @@ -36,44 +38,6 @@ export const SessionCreateOutput = objectType({
},
});

async function newSession(
prisma: PrismaClient,
session: Prisma.SessionCreateInput,
) {
return prisma.session.create({
data: session,
});
}

const generateTokenAndSession = async (
prisma: PrismaClient,
userId: string,
session: { expiryDurationSeconds?: number | null; name: string },
isUserCreated: boolean = false,
) => {
const createId = randomUUID();
const createdToken = tokenUtils.generate(createId, session.expiryDurationSeconds);
const expiryDate = tokenUtils.getExpiryDateFromToken(createdToken);
const formattedToken = tokenUtils.format(createdToken);
const createData = {
name: session.name,
referenceExpiryDate: expiryDate,
id: createId,
referenceTokenId: formattedToken,
isUserCreated,
creator: {
connect: {
id: userId,
},
},
};
const createdSession = await newSession(prisma, createData);
return {
token: createdToken,
session: createdSession,
};
};

export function getSessionCrud(prisma: PrismaClient) {
return {
listSessions: async (userId: string) => prisma.session.findMany({
Expand Down Expand Up @@ -115,19 +79,20 @@ export function getSessionCrud(prisma: PrismaClient) {
createSignInSession: async (userId: string) => generateTokenAndSession(
prisma,
userId,
{ expiryDurationSeconds: ms(JWT_EXPIRATION_PERIOD) / 1000, name: 'Sign in' },
{ expiryDurationSeconds: ms(JWT_EXPIRATION_PERIOD) / 1000, name: 'Sign in', allowedOrigins: OWN_ORIGIN },
),
createSignUpSession: async (userId: string) => generateTokenAndSession(
prisma,
userId,
{ expiryDurationSeconds: ms(JWT_EXPIRATION_PERIOD) / 1000, name: 'Sign up' },
{ expiryDurationSeconds: ms(JWT_EXPIRATION_PERIOD) / 1000, name: 'Sign up', allowedOrigins: OWN_ORIGIN },
),
createCustomSession: async (
userId: string,
session: { expiryDurationSeconds?: number | null; name: string },
session: { expiryDurationSeconds?: number | null; name: string, allowedOrigins: string },
isUserCreated: boolean = false,
) => generateTokenAndSession(prisma, userId, session, isUserCreated),
async getSessionByToken(
origin?: string,
token?: string,
) {
if (!token) {
Expand All @@ -150,6 +115,7 @@ export function getSessionCrud(prisma: PrismaClient) {
extensions: { code: 'SESSION_EXPIRED' },
});
}
validateOriginAgainstAllowed(session.allowedOrigins, origin);
return session;
},

Expand Down
1 change: 1 addition & 0 deletions api/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ test('Authentication: sign up, sign in, request protected enpoint', async () =>

const token = signInResponse?.signIn?.token;
ctx.client.setHeader('Authorization', `Bearer ${token}`);
ctx.client.setHeader('Origin', 'http://localhost:3000');

const meResponse = (await executeGraphQlQuery(meQuery)) as Record<
string,
Expand Down
Loading