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

fix: access token: fix utilisation multiple de la même route #893

Merged
merged 15 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ fileignoreconfig:
checksum: 8cdd1da6c1155f26b417a27e26311d4f00b7d8bd6c21f1f86c1c7cb3f0599e6a
- filename: server/.env.test
checksum: 2534c2dae48c1464b97489263621dcd516a676b28fdbb34e98267a10e00fd839
- filename: server/src/security/accessTokenService.ts
checksum: 671dbfb4d2d85ce2050c8c0755384028619e2ebb98616aca5bc7cd36704bb343
- filename: server/src/common/model/schema/_shared/mongoose-paginate.ts
checksum: b6762a7cb5df9bbee1f0ce893827f0991ad01514f7122a848b3b5d49b620f238
- filename: server/src/config.ts
Expand Down
4 changes: 2 additions & 2 deletions server/src/http/middlewares/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import {
import { IRouteSchema, SecurityScheme, WithSecurityScheme } from "shared/routes/common.routes"

import { authenticationMiddleware } from "@/security/authenticationService"
import { authorizationnMiddleware } from "@/security/authorisationService"
import { authorizationMiddleware } from "@/security/authorisationService"

const symbol = Symbol("authStrategy")

export function auth<S extends IRouteSchema & WithSecurityScheme>(schema: S) {
const authMiddleware = async (req: FastifyRequest) => {
await authenticationMiddleware(schema, req)
await authorizationnMiddleware(schema, req)
await authorizationMiddleware(schema, req)
}

authMiddleware[symbol] = schema.securityScheme
Expand Down
151 changes: 80 additions & 71 deletions server/src/security/accessTokenService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Boom from "boom"
import jwt from "jsonwebtoken"
import { PathParam, QueryString, WithQueryStringAndPathParam, generateUri } from "shared/helpers/generateUri"
import { PathParam, QueryString } from "shared/helpers/generateUri"
import { IUserRecruteur } from "shared/models"
import { IRouteSchema, ISecuredRouteSchema, WithSecurityScheme } from "shared/routes/common.routes"
import { IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes"
import { assertUnreachable } from "shared/utils"
import { Jsonify } from "type-fest"
import { AnyZodObject, z } from "zod"
Expand All @@ -15,22 +15,26 @@ const INTERNET_EXPLORER_V10_MAX_LENGTH = 2083
const OUTLOOK_URL_MAX_LENGTH = 8192
const NGINX_URL_MAX_LENGTH = 4096
const URL_MAX_LENGTH = Math.min(INTERNET_EXPLORER_V10_MAX_LENGTH, OUTLOOK_URL_MAX_LENGTH, NGINX_URL_MAX_LENGTH)
const TOKEN_MAX_LENGTH = URL_MAX_LENGTH - "https://labonnealternance.apprentissage.beta.gouv.fr/".length
const TOKEN_MAX_LENGTH = URL_MAX_LENGTH - (config.publicUrl.length + 1) // +1 for slash character

type SchemaWithSecurity = Pick<IRouteSchema, "method" | "path" | "params" | "querystring"> & WithSecurityScheme
export type SchemaWithSecurity = Pick<IRouteSchema, "method" | "path" | "params" | "querystring"> & WithSecurityScheme

type AllowAllType = { allowAll: true }
type AuthorizedValuesRecord<ZodObject> = ZodObject extends AnyZodObject
? {
[Key in keyof Jsonify<z.input<ZodObject>>]: Jsonify<z.input<ZodObject>>[Key] | AllowAllType
}
: undefined

// TODO à retirer à partir du 01/02/2024
type OldIScope<Schema extends SchemaWithSecurity> = {
schema: Schema
options:
| "all"
| {
params: Schema["params"] extends AnyZodObject ? Jsonify<z.input<Schema["params"]>> : undefined
querystring: Schema["querystring"] extends AnyZodObject ? Jsonify<z.input<Schema["querystring"]>> : undefined
params: AuthorizedValuesRecord<Schema["params"]>
querystring: AuthorizedValuesRecord<Schema["querystring"]>
}
resources: {
[key in keyof Schema["securityScheme"]["resources"]]: ReadonlyArray<string>
}
}

type NewIScope<Schema extends SchemaWithSecurity> = {
Expand All @@ -39,19 +43,16 @@ type NewIScope<Schema extends SchemaWithSecurity> = {
options:
| "all"
| {
params: Schema["params"] extends AnyZodObject ? Jsonify<z.input<Schema["params"]>> : undefined
querystring: Schema["querystring"] extends AnyZodObject ? Jsonify<z.input<Schema["querystring"]>> : undefined
params: AuthorizedValuesRecord<Schema["params"]>
querystring: AuthorizedValuesRecord<Schema["querystring"]>
}
resources: {
[key in keyof Schema["securityScheme"]["resources"]]: ReadonlyArray<string>
}
}

type IScope<Schema extends SchemaWithSecurity> = NewIScope<Schema> | OldIScope<Schema>

export const generateScope = <Schema extends SchemaWithSecurity>(scope: Omit<NewIScope<Schema>, "method" | "path"> & { schema: Schema }): NewIScope<Schema> => {
const { schema, options, resources } = scope
return { options, resources, path: schema.path, method: schema.method }
const { schema, options } = scope
return { options, path: schema.path, method: schema.method }
}

export type IAccessToken<Schema extends SchemaWithSecurity = SchemaWithSecurity> = {
Expand All @@ -69,27 +70,13 @@ export type IAccessToken<Schema extends SchemaWithSecurity = SchemaWithSecurity>
scopes: ReadonlyArray<IScope<Schema>>
}

function getAudience({
method,
path,
options,
skipParamsReplacement,
}: {
method: string
path: string
options: WithQueryStringAndPathParam
skipParamsReplacement: boolean
}): string {
return `${method} ${generateUri(path, options, skipParamsReplacement)}`.toLowerCase()
}

export function generateAccessToken(
user: IUserRecruteur | IAccessToken["identity"],
scopes: ReadonlyArray<NewIScope<ISecuredRouteSchema>>,
scopes: ReadonlyArray<NewIScope<SchemaWithSecurity>>,
options: { expiresIn?: string } = {}
): string {
const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUserRecruteur", _id: user._id.toString(), email: user.email.toLowerCase() } : user
const data: IAccessToken<ISecuredRouteSchema> = {
const data: IAccessToken<SchemaWithSecurity> = {
identity,
scopes,
}
Expand Down Expand Up @@ -117,11 +104,67 @@ function getMethodAndPath<Schema extends SchemaWithSecurity>(scope: IScope<Schem
}
}

export function getAccessTokenScope<Schema extends SchemaWithSecurity>(token: IAccessToken<Schema> | null, schema: Schema): IScope<Schema> | null {
function isAllowAllValue(x: unknown): x is AllowAllType {
return !!x && typeof x === "object" && "allowAll" in x && x.allowAll === true
}

function isAuthorizedParam(requiredValue: string, allowedValue: string | undefined | AllowAllType) {
return requiredValue === allowedValue || isAllowAllValue(allowedValue)
}

export function getAccessTokenScope<Schema extends SchemaWithSecurity>(
token: IAccessToken<Schema> | null,
schema: Schema,
params: PathParam | undefined,
querystring: QueryString | undefined
): IScope<Schema> | null {
return (
token?.scopes.find((scope) => {
const { method, path } = getMethodAndPath(scope)
return path === schema.path && method === schema.method
if (path !== schema.path || method !== schema.method) {
return false
}

if (scope.options === "all") {
return true
}

if (params) {
const allowedParams = scope.options.params
const isAuthorized = Object.entries(params).every(([key, requiredValue]) => {
const allowedParam = allowedParams?.[key]
return isAuthorizedParam(requiredValue, allowedParam)
})
if (!isAuthorized) {
return false
}
}

if (querystring) {
const allowedQueryString = scope.options.querystring
const isAuthorized = Object.entries(querystring).every(([key, value]) => {
const requiredValues = Array.isArray(value) ? new Set(value) : new Set([value])
const allowedValues = (allowedQueryString?.[key] ?? []) as string[] | string | AllowAllType
if (isAllowAllValue(allowedValues)) {
return true
}

if (Array.isArray(allowedValues)) {
for (const allowedValue of allowedValues) {
requiredValues.delete(allowedValue)
}
} else {
requiredValues.delete(allowedValues)
}

return requiredValues.size === 0
})
if (!isAuthorized) {
return false
}
}

return true
}) ?? null
)
}
Expand All @@ -137,43 +180,9 @@ export function parseAccessToken<Schema extends SchemaWithSecurity>(
issuer: config.publicUrl,
})
const token = data.payload as IAccessToken<Schema>
const specificAudience = getAudience({
method: schema.method,
path: schema.path,
options: {
params,
querystring,
},
skipParamsReplacement: false,
})
const genericAudience = getAudience({
method: schema.method,
path: schema.path,
options: {},
skipParamsReplacement: true,
})
const tokenAudiences: string[] = scopesToAudiences(token.scopes)
const isAuthorized = tokenAudiences.includes(specificAudience) || tokenAudiences.includes(genericAudience)
if (!isAuthorized) {
throw Boom.forbidden("Les audiences ne correspondent pas")
const scopeOpt = getAccessTokenScope(token, schema, params, querystring)
if (!scopeOpt) {
throw Boom.forbidden("Aucun scope ne correspond")
}
return token
}

function scopesToAudiences<Schema extends SchemaWithSecurity>(scopes: ReadonlyArray<IScope<Schema>>) {
return scopes.map((scope) => {
const { method, path } = getMethodAndPath(scope)
return getAudience({
method,
path,
options:
scope.options === "all"
? {}
: {
params: scope.options.params,
querystring: scope.options.querystring,
},
skipParamsReplacement: scope.options === "all",
})
})
}
Loading