Skip to content

Commit 5339b29

Browse files
committed
add oauth2
1 parent e3e6f7c commit 5339b29

8 files changed

Lines changed: 352 additions & 113 deletions

File tree

api-server/source/admin/append-registrations.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { assertRequestBody, defineRoute, HTTPError, useRandom } from "gruber";
22

3-
import { _startEmailLogin } from "../auth/login.ts";
3+
import { AuthLib } from "../auth/auth-lib.ts";
44
import {
55
RegistrationRecord,
66
RegistrationTable,
@@ -33,6 +33,7 @@ export const appendRegistrationsRoute = defineRoute({
3333
authz: useAuthz,
3434
sql: useDatabase,
3535
random: useRandom,
36+
authLib: AuthLib.use,
3637
},
3738
async handler({
3839
request,
@@ -44,6 +45,7 @@ export const appendRegistrationsRoute = defineRoute({
4445
store,
4546
email,
4647
random,
48+
authLib,
4749
}) {
4850
await authz.assert(request, { scope: "admin" });
4951

@@ -119,20 +121,16 @@ export const appendRegistrationsRoute = defineRoute({
119121
// Send login emails to new users
120122
if (sendEmail) {
121123
for (const user of result.users.records) {
122-
const login = {
124+
await authLib.startEmailLogin({
125+
method: "email",
123126
token: random.uuid(),
124127
code: random.number(0, 999_999),
125128
redirectUri,
126129
uses: 5,
127-
};
128-
129-
await _startEmailLogin(
130-
store,
131-
email,
132-
login,
133-
user.email,
134-
appConfig.auth.loginMaxAge,
135-
);
130+
payload: {
131+
emailAddress: user.email,
132+
},
133+
});
136134
}
137135
}
138136

api-server/source/auth/auth-lib.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,113 @@
1-
// NOTE: to be converted to a union type in the future for more methods
2-
export interface LoginRequest {
1+
import cookie from "cookie";
2+
import { HTTPError, loader, Store, TokenService } from "gruber";
3+
import { AppConfig } from "../config.ts";
4+
import {
5+
EmailService,
6+
useAppConfig,
7+
useEmail,
8+
useStore,
9+
useTokens,
10+
} from "../lib/mod.ts";
11+
12+
export interface EmailLoginRequest {
13+
method: "email";
314
token: string;
415
payload: {
516
emailAddress: string;
617
};
718
code: number;
8-
method: "email";
919
redirectUri: string;
1020
uses: number;
1121
}
22+
23+
export interface OauthLoginRequest {
24+
method: "oauth";
25+
provider: "google";
26+
token: string;
27+
redirectUri: string;
28+
}
29+
30+
export type LoginRequest = EmailLoginRequest | OauthLoginRequest;
31+
32+
export class AuthLib {
33+
static use = loader(() => {
34+
return new AuthLib(useTokens(), useAppConfig(), useStore(), useEmail());
35+
});
36+
37+
appConfig: AppConfig;
38+
tokens: TokenService;
39+
store: Store;
40+
email: EmailService;
41+
constructor(
42+
tokens: TokenService,
43+
appConfig: AppConfig,
44+
store: Store,
45+
email: EmailService,
46+
) {
47+
this.tokens = tokens;
48+
this.appConfig = appConfig;
49+
this.store = store;
50+
this.email = email;
51+
}
52+
53+
async startEmailLogin(init: EmailLoginRequest) {
54+
// Store the login to be retrieved on clicking through from the email
55+
this.store.set<LoginRequest>(`/auth/request/${init.token}`, init, {
56+
maxAge: this.appConfig.auth.loginMaxAge,
57+
});
58+
59+
// Generate the magic link to send the client with the token + code in it
60+
const magicLink = new URL(init.redirectUri);
61+
magicLink.searchParams.set("method", "email");
62+
magicLink.searchParams.set("token", init.token);
63+
magicLink.searchParams.set("code", init.code.toString());
64+
65+
// Send the email
66+
const sent = await this.email.sendTemplated({
67+
to: { emailAddress: init.payload.emailAddress },
68+
type: "login",
69+
arguments: {
70+
oneTimeCode: init.code,
71+
magicLink: magicLink.toString(),
72+
},
73+
});
74+
75+
if (!sent) {
76+
throw HTTPError.internalServerError("login email failed");
77+
}
78+
}
79+
80+
async finish(login: LoginRequest, userId: number, scope: string) {
81+
// Generate a session token for the user
82+
const sessionToken = await this.tokens.sign(scope, {
83+
maxAge: this.appConfig.auth.sessionMaxAge,
84+
userId: userId,
85+
});
86+
87+
// Generate headers to set the session token and clear the login token
88+
const headers = new Headers();
89+
headers.append(
90+
"Set-Cookie",
91+
cookie.serialize(this.appConfig.auth.sessionCookie, sessionToken, {
92+
httpOnly: true,
93+
maxAge: this.appConfig.auth.sessionMaxAge / 1_000,
94+
secure: this.appConfig.server.url.protocol === "https:",
95+
}),
96+
);
97+
headers.append(
98+
"Set-Cookie",
99+
cookie.serialize(this.appConfig.auth.loginCookie, "", {
100+
expires: new Date(0),
101+
}),
102+
);
103+
104+
// Clear the login from the store
105+
await this.store.delete(`/auth/request/${login.token}`);
106+
107+
return { headers, sessionToken };
108+
}
109+
}
110+
111+
export function parseScopes(input: string) {
112+
return new Set(input.split(/\s+/).filter((s) => s));
113+
}

api-server/source/auth/auth-repo.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { loader, SqlDependency } from "gruber";
33
import {
44
assertRequestParam,
55
ConferenceTable,
6+
Oauth2TokenRecord,
7+
OAuth2TokenTable,
68
RegistrationTable,
79
useDatabase,
810
UserTable,
@@ -38,4 +40,8 @@ export class AuthRepo {
3840
this.sql`id = ${assertRequestParam(id)}`,
3941
);
4042
}
43+
44+
createToken(init: Omit<Oauth2TokenRecord, "id" | "created_at">) {
45+
return OAuth2TokenTable.insertOne(this.sql, init);
46+
}
4147
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { loginRoute } from "./login.ts";
22
import { getAuthRoute } from "./me.ts";
3+
import { oauthRoute } from "./oauth.ts";
34
import { verifyRoute } from "./verify.ts";
45

5-
export const authRoutes = [loginRoute, verifyRoute, getAuthRoute];
6+
export const authRoutes = [loginRoute, verifyRoute, getAuthRoute, oauthRoute];

api-server/source/auth/login.ts

Lines changed: 65 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,34 @@
11
import cookie from "cookie";
2-
import {
3-
assertRequestBody,
4-
defineRoute,
5-
HTTPError,
6-
Store,
7-
Structure,
8-
} from "gruber";
2+
import { assertRequestBody, defineRoute, HTTPError, Structure } from "gruber";
93

104
import {
115
commponDependencies,
126
ConferenceRecord,
13-
EmailService,
147
emailStructure,
8+
GOOGLE_CALENDAR_SCOPE,
9+
GoogleRepo,
1510
trimEmail,
11+
undefinedStructure,
1612
} from "../lib/mod.ts";
17-
import { LoginRequest } from "./auth-lib.ts";
13+
import { AuthLib, LoginRequest, parseScopes } from "./auth-lib.ts";
1814
import { AuthRepo } from "./auth-repo.ts";
1915

2016
const LoginBody = Structure.union([
2117
Structure.object({
18+
type: Structure.literal("email"),
2219
emailAddress: emailStructure(),
2320
redirectUri: Structure.string(),
2421
conferenceId: Structure.union([Structure.null(), Structure.number()]),
2522
}),
23+
Structure.object({
24+
type: Structure.literal("oauth"),
25+
provider: Structure.literal("google"),
26+
redirectUri: Structure.string(),
27+
conferenceId: Structure.union([Structure.null(), Structure.number()]),
28+
scope: Structure.union([Structure.string(), undefinedStructure()]),
29+
}),
2630
]);
2731

28-
// This is exported so that it can be used in the tito webhook too
29-
export async function _startEmailLogin(
30-
store: Store,
31-
email: EmailService,
32-
login: Omit<LoginRequest, "method" | "payload">,
33-
emailAddress: string,
34-
maxAge: number,
35-
) {
36-
// Store the login to be retrieved on clicking through from the email
37-
store.set<LoginRequest>(
38-
`/auth/request/${login.token}`,
39-
{
40-
...login,
41-
method: "email",
42-
payload: { emailAddress },
43-
},
44-
{ maxAge },
45-
);
46-
47-
// Generate the magic link to send the client with the token + code in it
48-
const magicLink = new URL(login.redirectUri);
49-
magicLink.searchParams.set("method", "email");
50-
magicLink.searchParams.set("token", login.token);
51-
magicLink.searchParams.set("code", login.code.toString());
52-
53-
// Send the email
54-
const sent = await email.sendTemplated({
55-
to: { emailAddress },
56-
type: "login",
57-
arguments: {
58-
oneTimeCode: login.code,
59-
magicLink: magicLink.toString(),
60-
},
61-
});
62-
63-
if (!sent) {
64-
throw HTTPError.internalServerError("login email failed");
65-
}
66-
}
67-
6832
function stripRedirect(input: string) {
6933
const url = new URL(input);
7034
url.search = "";
@@ -89,13 +53,14 @@ export const loginRoute = defineRoute({
8953
dependencies: {
9054
...commponDependencies,
9155
repo: AuthRepo.use,
56+
lib: AuthLib.use,
57+
google: GoogleRepo.use,
9258
},
93-
async handler({ request, authz, store, random, email, appConfig, repo }) {
59+
async handler({ request, store, random, lib, appConfig, repo, google }) {
9460
// NOTE: it previously short-circuited the login if there was an active session
9561
// this cased confusion so was taken out
9662

9763
const body = await assertRequestBody(LoginBody, request);
98-
const emailAddress = trimEmail(body.emailAddress);
9964

10065
const conference = body.conferenceId
10166
? await repo.getConference(body.conferenceId)
@@ -110,28 +75,32 @@ export const loginRoute = defineRoute({
11075
uses: 5,
11176
};
11277

113-
if (body.emailAddress) {
78+
const cookieOptions = {
79+
httpOnly: true,
80+
maxAge: appConfig.auth.loginMaxAge / 1_000,
81+
secure: appConfig.server.url.protocol === "https:",
82+
};
83+
84+
if (body.type === "email") {
85+
const emailAddress = trimEmail(body.emailAddress);
86+
11487
// TODO: should it verify conference registration too?
11588
const user = await repo.getUserByEmail(emailAddress);
11689
if (!user) throw HTTPError.unauthorized();
11790

11891
// Send the email login, throwing if it fails
119-
await _startEmailLogin(
120-
store,
121-
email,
122-
login,
123-
emailAddress,
124-
appConfig.auth.loginMaxAge,
125-
);
92+
await lib.startEmailLogin({
93+
...login,
94+
method: "email",
95+
payload: { emailAddress },
96+
});
12697

12798
// Set the login cookie on the client
12899
const headers = new Headers();
129100
headers.set(
130101
"Set-Cookie",
131102
cookie.serialize(appConfig.auth.loginCookie, login.token, {
132-
httpOnly: true,
133-
maxAge: appConfig.auth.loginMaxAge / 1_000,
134-
secure: appConfig.server.url.protocol === "https:",
103+
...cookieOptions,
135104
}),
136105
);
137106

@@ -140,6 +109,40 @@ export const loginRoute = defineRoute({
140109
return Response.json({ token: login.token }, { headers });
141110
}
142111

112+
if (body.type === "oauth") {
113+
const scope = [
114+
"https://www.googleapis.com/auth/userinfo.email",
115+
"openid",
116+
"profile",
117+
];
118+
119+
const requested = body.scope
120+
? parseScopes(body.scope)
121+
: new Set<string>();
122+
123+
if (requested.has("calendar")) {
124+
scope.push(GOOGLE_CALENDAR_SCOPE);
125+
}
126+
127+
await store.set<LoginRequest>(`/auth/request/${login.token}`, {
128+
...login,
129+
method: "oauth",
130+
provider: "google",
131+
});
132+
133+
const headers = new Headers();
134+
headers.set(
135+
"Set-Cookie",
136+
cookie.serialize("oauth2-state", login.token, cookieOptions),
137+
);
138+
139+
const url = google.authUrl(scope, login.token, requested);
140+
141+
// NOTE: you cannot access headers.location from JavaScript
142+
// This method would make more sense as a GET request, then cookies can be set too
143+
return Response.json({ location: url }, { headers });
144+
}
145+
143146
throw HTTPError.notImplemented();
144147
},
145148
});

0 commit comments

Comments
 (0)