-
Notifications
You must be signed in to change notification settings - Fork 20
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; |
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
} | ||
) | ||
} | ||
} | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
@@ -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, | ||
|
@@ -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" | ||
|
@@ -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"); | ||
} | ||
|
||
|
@@ -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 | ||
} | ||
} | ||
|
||
|
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
@@ -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() | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aaaaah good catch