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 /auth/token endpoint for service to exchange secret for api token #656

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bot" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ model User {
name String
githubId String? @unique
githubImageUrl String?
bot Boolean @default(false)

// TODO: connect with github ID
// TODO: store github profile image - or get it via API
Expand Down
18 changes: 6 additions & 12 deletions src/api/plugins/auth.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { FastifyInstance, RouteGenericInterface } from 'fastify'
import fp from 'fastify-plugin'
import { User } from '@prisma/client'

import apiConfig from '../../config/api'
import { authService } from '../services/authService'

declare module 'fastify' {
export interface FastifyRequest {
user: User | null
}

export interface AuthenticatedFastifyRequest<T extends RouteGenericInterface>
extends FastifyRequest<T> {
export interface AuthenticatedFastifyRequest<T extends RouteGenericInterface> extends FastifyRequest<T> {
user: User
}
}
Expand All @@ -26,24 +23,21 @@ async function authPlugin(app: FastifyInstance) {
try {
const token = request.headers['authorization']?.replace('Bearer ', '')

if (!token || !apiConfig.tokens?.includes(token)) {
request.log.error('No token', {
token,
apiConfigTokens: apiConfig.tokens,
})
if (!token) {
request.log.error('No token provided')
return reply.status(401).send(unauthorizedError)
}

const { user, newToken } = authService.verifyUser(token)
const { user, newToken } = authService.verifyToken(token)

if (newToken !== undefined) {
reply.headers['x-auth-token'] = newToken
} else {
request.log.error('No user found')
request.log.error('Could not create new token for user', user)
Copy link
Contributor

Choose a reason for hiding this comment

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

newToken can have the value undefined if the current token is still valid for longer than 15min, in that case it is not necessary to provide a new token. So catching the case that the token is undefined as an error is wrong here.

Copy link
Author

Choose a reason for hiding this comment

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

aaaaah good catch

}
request.user = user
} catch (err) {
request.log.error(err)
request.log.error('Authentication failed:', err)
return reply.status(401).send(unauthorizedError)
}
})
Expand Down
86 changes: 54 additions & 32 deletions src/api/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,62 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import { getTags } from "../../config/openapi";
import { authenticationBodySchema, AuthentificationResponseScheme, getErrorSchemas } from "../schemas";
import { authenticationBody } from "../types";
import { userAuthenticationBodySchema, AuthentificationResponseScheme, getErrorSchemas, serviceAuthenticationBodySchema } from "../schemas";
import { userAuthenticationBody, serviceAuthenticationBody } from "../types";
import { authService } from "../services/authService";

const unauthorizedError = {
message: 'Unauthorized',
}


export async function authentificationRoutes(app: FastifyInstance) {
app.post(
'/github',
{
schema: {
summary: 'Auth User with Github Identity',
description: 'Authenticates a user using a Github access code',
tags: getTags('Auth'),
response: {
200: AuthentificationResponseScheme,
...getErrorSchemas(401)
},
body: authenticationBodySchema
app.post(
'/github',
{
schema: {
summary: 'Auth User with Github Identity',
description: 'Authenticates a user using a Github access code',
tags: getTags('Auth'),
response: {
200: AuthentificationResponseScheme,
...getErrorSchemas(401)
},
body: userAuthenticationBodySchema
},
},
async (
request: FastifyRequest<{Body: userAuthenticationBody}>,
reply
) => {
try {
const token = await authService.authorizeUser(request.body.code);
reply.status(200).send({token});
} catch(error) {
console.log(error);
return reply.status(401).send()
}
}
)
app.post(
Copy link
Contributor

Choose a reason for hiding this comment

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

Great work🚀

'/token',
{
schema: {
summary: 'Auth Client with API Secret',
description: 'Authenticates a client using a service secret',
tags: getTags('Auth'),
response: {
200: AuthentificationResponseScheme,
...getErrorSchemas(401)
},
body: serviceAuthenticationBodySchema
},
async (
request: FastifyRequest<{Body: authenticationBody}>,
reply
) => {
try {
const token = await authService.authUser(request.body.code);
reply.status(200).send({token});
} catch(error) {
console.log(error);
return reply.status(401).send()
}
},
async (
request: FastifyRequest<{Body: serviceAuthenticationBody}>,
reply
) => {
try {
const token = await authService.authorizeService(request.body);
reply.status(200).send({token});
} catch(error) {
console.log(error);
return reply.status(401).send()
}
)
}
}
)
}
7 changes: 6 additions & 1 deletion src/api/schemas/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ export const MunicipalityNameParamSchema = z.object({
name: MunicipalityNameSchema,
})

export const authenticationBodySchema = z.object({
export const userAuthenticationBodySchema = z.object({
code: z.string()
})

export const serviceAuthenticationBodySchema = z.object({
client_id: z.string(),
client_secret: z.string()
})
54 changes: 44 additions & 10 deletions src/api/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import axios from "axios";
import apiConfig from "../../config/api"
import { prisma } from "../../lib/prisma";
import jwt from "jsonwebtoken";
import { serviceAuthenticationBody } from "../types";

interface Userinfo {
interface GithubUserinfo {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not happy with this name as the user info acts both for github users and non-github clients, so keeping the more general name userinfo might be better, also that one is not perfect. But, to fully fix this we would have to separate real users and clients.

login: string,
avatar_url: string,
name: string,
Expand All @@ -16,10 +17,11 @@ interface User {
email: string,
githubId: string | null,
githubImageUrl: string | null
bot: boolean
}

class AuthService {
async authUser(code: string) {
async authorizeUser(code: string) {

const accessTokenRes = await axios.post<{access_token: string}>("https://github.com/login/oauth/access_token", {
client_id: apiConfig.githubClientId,
Expand All @@ -32,7 +34,7 @@ class AuthService {

const accessToken = accessTokenRes.data.access_token;

const userinfoRes = await axios.get<Userinfo>("https://api.github.com/user", {
const userinfoRes = await axios.get<GithubUserinfo>("https://api.github.com/user", {
headers: {
Authorization: "Bearer " + accessToken,
Accept: "application/vnd.github+json"
Expand All @@ -41,14 +43,14 @@ class AuthService {

const userinfo = userinfoRes.data;

const isMemeber = await axios.get("https://api.github.com/orgs/" + apiConfig.githubOrganization + "/members/" + userinfo.login, {
const isMember = await axios.get("https://api.github.com/orgs/" + apiConfig.githubOrganization + "/members/" + userinfo.login, {
headers: {
Authorization: "Bearer " + accessToken,
Accept: "application/vnd.github+json"
}
})

if(isMemeber.status !== 204) {
if(isMember.status !== 204) {
throw new Error("User is not member of the organization");
}

Expand All @@ -71,20 +73,52 @@ class AuthService {

return this.createToken(user);
}

async authorizeService(serviceAuth: serviceAuthenticationBody) {
if (serviceAuth.client_secret !== apiConfig.secret) {
throw new Error("Invalid secret");
}

let user = await prisma.user.findFirst({
where: {
name: serviceAuth.client_id
}
})

if(!user) {
user = await prisma.user.upsert({
where: {
email: serviceAuth.client_id + "@klimatkollen.se"
},
update: {
name: serviceAuth.client_id,
email: serviceAuth.client_id + "@klimatkollen.se",
bot: true
},
create: {
name: serviceAuth.client_id,
email: serviceAuth.client_id + "@klimatkollen.se",
bot: true
}
})
}

return this.createToken(user);
}

private static readonly TOKEN_EXPIRY_BUFFER_MINUTES = 15;
private static readonly SECONDS_IN_A_MINUTE = 60;

verifyUser(token: string) {
const payload = jwt.verify(token, apiConfig.jwtSecret) as User & {exp: number};
verifyToken(token: string) {
const user = jwt.verify(token, apiConfig.jwtSecret) as User & {exp: number};
const currentTimeInSeconds = Date.now() / 1000;
const renewalThreshold = AuthService.TOKEN_EXPIRY_BUFFER_MINUTES * AuthService.SECONDS_IN_A_MINUTE;

const shouldRenew = currentTimeInSeconds > payload.exp - renewalThreshold;
const shouldRenew = currentTimeInSeconds > user.exp - renewalThreshold;

return {
user: payload,
newToken: shouldRenew ? this.createToken(payload) : undefined
user,
newToken: shouldRenew ? this.createToken(user) : undefined
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export type MunicipalityNameParams = z.infer<
typeof schemas.MunicipalityNameParamSchema
>

export type authenticationBody = z.infer<
typeof schemas.authenticationBodySchema
export type userAuthenticationBody = z.infer<
typeof schemas.userAuthenticationBodySchema
>

export type serviceAuthenticationBody = z.infer<
typeof schemas.serviceAuthenticationBodySchema
>
4 changes: 2 additions & 2 deletions src/config/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const envSchema = z.object({
* Comma-separated list of API tokens. E.g. garbo:lk3h2k1,alex:ax32bg4
* NOTE: This is only relevant during import with alex data, and then we switch to proper auth tokens.
*/
API_TOKENS: z.string().transform((tokens) => tokens.split(',')),
API_SECRET: z.string(),
FRONTEND_URL: z
.string()
.default(
Expand Down Expand Up @@ -72,7 +72,7 @@ const apiConfig = {
? productionOrigins
: developmentOrigins,

tokens: env.API_TOKENS,
secret: env.API_SECRET,
frontendURL: env.FRONTEND_URL,
baseURL: env.API_BASE_URL,
port: env.PORT,
Expand Down
23 changes: 22 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import apiConfig from '../config/api'

const GARBO_TOKEN = apiConfig.tokens.find((token) => token.startsWith('garbo'))
let GARBO_TOKEN = await getApiToken(apiConfig.secret)

async function getApiToken(secret: string) {
const response = await fetch(`${apiConfig.baseURL}/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: 'garbo',
client_secret: secret,
}),
})

const token = (await response.json()).token
console.log(token)
Copy link
Contributor

Choose a reason for hiding this comment

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

can be removed before merge

return token
}

export async function apiFetch(
endpoint: string,
Expand All @@ -24,6 +41,10 @@ export async function apiFetch(

const response = await fetch(`${apiConfig.baseURL}${endpoint}`, config)
if (response.ok) {
const newToken = response.headers.get('x-auth-token')
if (newToken) {
GARBO_TOKEN = newToken
}
return response.json()
} else {
const errorMessage = await response.text()
Expand Down