1//! https://oauth.net/2/
2
3const std = @import("std");
4const string = []const u8;
5const files = @import("./files.zig");
6const pek = @import("pek");
7const extras = @import("extras");
8const url = @import("url");
9const http = @import("http");
10const nio = @import("nio");
11const json = @import("json");
12const builtin = @import("builtin");
13const root = @import("root");
14const Base = @This();
15
16pub const Provider = struct {
17 id: string,
18 authorize_url: string,
19 token_url: string,
20 me_url: string,
21 scope: string = "",
22 name_prop: string,
23 name_prefix: string = "",
24 id_prop: string = "id",
25 logo: string,
26 color: string,
27
28 pub fn real_id(self: Provider) string {
29 if (std.mem.indexOfScalar(u8, self.id, ',')) |_| {
30 var iter = std.mem.splitScalar(u8, self.id, ',');
31 return iter.next().?;
32 }
33 return self.id;
34 }
35
36 pub fn domain(self: Provider) string {
37 if (std.mem.indexOfScalar(u8, self.id, ',')) |_| {
38 var iter = std.mem.splitScalar(u8, self.id, ',');
39 _ = iter.next();
40 return iter.next().?;
41 }
42 return self.id;
43 }
44};
45
46pub const Client = struct {
47 provider: Provider,
48 id: string,
49 secret: string,
50};
51
52fn icon_url(comptime name: string) string {
53 return "https://unpkg.com/simple-icons@" ++ "5.13.0" ++ "/icons/" ++ name ++ ".svg";
54}
55
56pub const providers = struct {
57 pub var amazon = Provider{
58 .id = "amazon",
59 .authorize_url = "https://www.amazon.com/ap/oa",
60 .token_url = "https://api.amazon.com/auth/o2/token",
61 .me_url = "https://api.amazon.com/user/profile",
62 .scope = "profile",
63 .name_prop = "name",
64 .id_prop = "user_id",
65 .logo = icon_url("amazon"),
66 .color = "#FF9900",
67 };
68 pub var battle_net = Provider{
69 .id = "battle.net",
70 .authorize_url = "https://us.battle.net/oauth/authorize",
71 .token_url = "https://us.battle.net/oauth/token",
72 .me_url = "https://us.battle.net/oauth/userinfo",
73 .scope = "openid",
74 .name_prop = "battletag",
75 .logo = icon_url("battle-dot-net"),
76 .color = "#00AEFF",
77 };
78 pub var discord = Provider{
79 .id = "discord",
80 .authorize_url = "https://discordapp.com/api/oauth2/authorize",
81 .token_url = "https://discordapp.com/api/oauth2/token",
82 .me_url = "https://discordapp.com/api/users/@me",
83 .scope = "identify",
84 .name_prop = "username",
85 .name_prefix = "@",
86 .logo = icon_url("discord"),
87 .color = "#7289DA",
88 };
89 pub var facebook = Provider{
90 .id = "facebook",
91 .authorize_url = "https://graph.facebook.com/oauth/authorize",
92 .token_url = "https://graph.facebook.com/oauth/access_token",
93 .me_url = "https://graph.facebook.com/me",
94 .name_prop = "name",
95 .logo = icon_url("facebook"),
96 .color = "#1877F2",
97 };
98 pub var github = Provider{
99 .id = "github",
100 .authorize_url = "https://github.com/login/oauth/authorize",
101 .token_url = "https://github.com/login/oauth/access_token",
102 .me_url = "https://api.github.com/user",
103 .scope = "read:user",
104 .name_prop = "login",
105 .name_prefix = "@",
106 .logo = icon_url("github"),
107 .color = "#181717",
108 };
109 pub var google = Provider{
110 .id = "google",
111 .authorize_url = "https://accounts.google.com/o/oauth2/v2/auth",
112 .token_url = "https://www.googleapis.com/oauth2/v4/token",
113 .me_url = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
114 .scope = "profile",
115 .name_prop = "name",
116 .logo = icon_url("google"),
117 .color = "#4285F4",
118 };
119 pub var microsoft = Provider{
120 .id = "microsoft",
121 .authorize_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
122 .token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token",
123 .me_url = "https://graph.microsoft.com/v1.0/me/",
124 .scope = "https://graph.microsoft.com/user.read",
125 .name_prop = "displayName",
126 .logo = icon_url("microsoft"),
127 .color = "#666666",
128 };
129 pub var reddit = Provider{
130 .id = "reddit",
131 .authorize_url = "https://old.reddit.com/api/v1/authorize",
132 .token_url = "https://old.reddit.com/api/v1/access_token",
133 .me_url = "https://oauth.reddit.com/api/v1/me",
134 .scope = "identity",
135 .name_prop = "name",
136 .name_prefix = "u/",
137 .logo = icon_url("reddit"),
138 .color = "#FF4500",
139 };
140 pub var railway = Provider{
141 .id = "railway",
142 .authorize_url = "https://backboard.railway.com/oauth/auth",
143 .token_url = "https://backboard.railway.com/oauth/token",
144 .me_url = "https://backboard.railway.com/oauth/me",
145 .scope = "openid+profile",
146 .name_prop = "name",
147 .name_prefix = "",
148 .logo = icon_url("railway"),
149 .color = "#0B0D0E",
150 };
151};
152
153pub const dynamic_providers = struct {
154 pub const _gitea = Provider{
155 .id = "gitea",
156 .authorize_url = "https://{[domain]s}/login/oauth/authorize",
157 .token_url = "https://{[domain]s}/login/oauth/access_token",
158 .me_url = "https://{[domain]s}/api/v1/user",
159 .name_prop = "username",
160 .name_prefix = "@",
161 .logo = icon_url("gitea"),
162 .color = "#609926",
163 };
164 pub const _forgejo = Provider{
165 .id = "forgejo",
166 .authorize_url = "https://{[domain]s}/login/oauth/authorize",
167 .token_url = "https://{[domain]s}/login/oauth/access_token",
168 .me_url = "https://{[domain]s}/api/v1/user",
169 .name_prop = "username",
170 .name_prefix = "@",
171 .logo = icon_url("forgejo"),
172 .color = "#FB923C",
173 };
174 pub const _gitlab = Provider{
175 .id = "gitlab",
176 .authorize_url = "https://{[domain]s}/oauth/authorize",
177 .token_url = "https://{[domain]s}/oauth/token",
178 .me_url = "https://{[domain]s}/api/v4/user",
179 .scope = "read_user",
180 .name_prop = "username",
181 .name_prefix = "@",
182 .logo = icon_url("gitlab"),
183 .color = "#FCA121",
184 };
185 pub const _mastodon = Provider{
186 .id = "mastodon",
187 .authorize_url = "https://{[domain]s}/oauth/authorize",
188 .token_url = "https://{[domain]s}/oauth/token",
189 .me_url = "https://{[domain]s}/api/v1/accounts/verify_credentials",
190 .scope = "read:accounts",
191 .name_prop = "username",
192 .name_prefix = "@",
193 .logo = icon_url("mastodon"),
194 .color = "#3088D4",
195 };
196 pub const _pleroma = Provider{
197 .id = "pleroma",
198 .authorize_url = "https://{[domain]s}/oauth/authorize",
199 .token_url = "https://{[domain]s}/oauth/token",
200 .me_url = "https://{[domain]s}/api/v1/accounts/verify_credentials",
201 .scope = "read:accounts",
202 .name_prop = "username",
203 .name_prefix = "@",
204 .logo = icon_url("pleroma"),
205 .color = "#FBA457",
206 };
207};
208
209pub fn providerById(alloc: std.mem.Allocator, name: string) !?Provider {
210 inline for (comptime std.meta.declarations(providers)) |item| {
211 const p = @field(providers, item.name);
212 if (std.mem.eql(u8, p.id, name)) {
213 return p;
214 }
215 }
216 inline for (comptime extras.globalOption("oauth2_providers", []const Provider) orelse &.{}) |p| {
217 if (std.mem.eql(u8, p.id, name)) {
218 return p;
219 }
220 }
221 const c_ind = std.mem.indexOfScalar(u8, name, ',') orelse return null;
222 const p_id = name[0..c_ind];
223 const domain = name[c_ind + 1 ..];
224 const args = .{ .domain = domain };
225 inline for (comptime std.meta.declarations(dynamic_providers)) |item| {
226 const didp = @field(dynamic_providers, item.name);
227 if (std.mem.eql(u8, didp.id, p_id)) {
228 return Provider{
229 .id = name,
230 .authorize_url = try nio.fmt.allocPrint(alloc, didp.authorize_url, args),
231 .token_url = try nio.fmt.allocPrint(alloc, didp.token_url, args),
232 .me_url = try nio.fmt.allocPrint(alloc, didp.me_url, args),
233 .scope = didp.scope,
234 .name_prop = didp.name_prop,
235 .name_prefix = didp.name_prefix,
236 .id_prop = didp.id_prop,
237 .logo = didp.logo,
238 .color = didp.color,
239 };
240 }
241 }
242 if (std.mem.eql(u8, p_id, "oidc")) {
243 const io = root.io;
244 var buf: [4096]u8 = @splat(0);
245 var http_client: std.http.Client = .{ .allocator = alloc, .io = io };
246 defer http_client.deinit();
247
248 const url_s = try nio.fmt.allocPrint(alloc, "https://{s}/.well-known/openid-configuration", .{domain});
249 defer alloc.free(url_s);
250
251 var req = try http_client.request(.GET, try std.Uri.parse(url_s), .{
252 .headers = .{
253 .accept_encoding = .{ .override = "identity" },
254 },
255 .redirect_behavior = .not_allowed,
256 });
257 defer req.deinit();
258 try req.sendBodiless();
259 var resp = try req.receiveHead(&.{});
260 const body_content = try resp.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5));
261 defer alloc.free(body_content);
262 if (resp.head.status != .ok) std.log.scoped(.oauth).err("GET '{s}': {d}", .{ url_s, resp.head.status });
263 if (resp.head.status != .ok) std.log.scoped(.oauth).err("{s}", .{body_content});
264 if (resp.head.status != .ok) return null;
265 const val = try json.parseFromSlice(alloc, "body.json", body_content, .{ .maximum_depth = 100, .support_trailing_commas = true });
266 defer val.deinit(alloc);
267 val.acquire();
268 defer val.release();
269 const rootobj = val.root.object();
270 const authorize_url = rootobj.getS("authorization_endpoint") orelse {
271 std.log.scoped(.oauth).err("openid-configuration did not have the expected 'authorization_endpoint' key", .{});
272 return null;
273 };
274 const token_url = rootobj.getS("token_endpoint") orelse {
275 std.log.scoped(.oauth).err("openid-configuration did not have the expected 'token_endpoint' key", .{});
276 return null;
277 };
278 const me_url = rootobj.getS("userinfo_endpoint") orelse {
279 std.log.scoped(.oauth).err("openid-configuration did not have the expected 'userinfo_endpoint' key", .{});
280 return null;
281 };
282 return Provider{
283 .id = name,
284 .authorize_url = try alloc.dupe(u8, authorize_url),
285 .token_url = try alloc.dupe(u8, token_url),
286 .me_url = try alloc.dupe(u8, me_url),
287 .scope = "openid",
288 .name_prop = "preferred_username",
289 .name_prefix = "",
290 .id_prop = "sub",
291 .logo = icon_url("openid"),
292 .color = "#F78C40",
293 };
294 }
295 inline for (comptime extras.globalOption("oauth2_dynamic_providers", []const Provider) orelse &.{}) |didp| {
296 if (std.mem.eql(u8, didp.id, p_id)) {
297 return Provider{
298 .id = name,
299 .authorize_url = try nio.fmt.allocPrint(alloc, didp.authorize_url, args),
300 .token_url = try nio.fmt.allocPrint(alloc, didp.token_url, args),
301 .me_url = try nio.fmt.allocPrint(alloc, didp.me_url, args),
302 .scope = didp.scope,
303 .name_prop = didp.name_prop,
304 .name_prefix = didp.name_prefix,
305 .id_prop = didp.id_prop,
306 .logo = didp.logo,
307 .color = didp.color,
308 };
309 }
310 }
311 return null;
312}
313
314pub fn clientByProviderId(clients: []const Client, name: string) ?Client {
315 for (clients) |item| {
316 if (std.mem.eql(u8, name, item.provider.id)) {
317 return item;
318 }
319 }
320 return null;
321}
322
323pub fn Handlers(comptime T: type) type {
324 return struct {
325 const Self = @This();
326 pub var clients: []Client = &.{};
327
328 pub fn login(request: *http.ServerRequest, body_writer: anytype, alloc: std.mem.Allocator, query: url.SearchParams, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void {
329 if (query.get("with")) |with| {
330 const client = clientByProviderId(Self.clients, with) orelse return try fail(response_status, body_writer, "Client with that ID not found!\n", .{});
331 return try loginOne(request, alloc, T, client, T.callbackPath, request_headers, response_status, response_headers);
332 }
333 if (Self.clients.len == 1) {
334 return try loginOne(request, alloc, T, clients[0], T.callbackPath, request_headers, response_status, response_headers);
335 }
336
337 try response_headers.append("content-type", "text/html");
338 const page = files.@"/selector.pek";
339 const tmpl = comptime pek.parse(page);
340 try pek.compile(Base, alloc, body_writer, tmpl, .{
341 .clients = Self.clients,
342 });
343 }
344
345 pub fn callback(request: *http.ServerRequest, body_writer: anytype, alloc: std.mem.Allocator, query: url.SearchParams, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void {
346 _ = request;
347 const state = query.get("state") orelse return try fail(response_status, body_writer, "", .{});
348 const client = clientByProviderId(Self.clients, state) orelse return try fail(response_status, body_writer, "error: No handler found for provider: {s}\n", .{state});
349 const code = query.get("code") orelse return try fail(response_status, body_writer, "", .{});
350
351 const io = if (!builtin.is_test) root.io else std.Options.debug_io;
352 var buf: [4096]u8 = @splat(0);
353 var http_client: std.http.Client = .{ .allocator = alloc, .io = io };
354 defer http_client.deinit();
355
356 var params = url.SearchParams.init(alloc);
357 try params.append("client_id", client.id);
358 try params.append("client_secret", client.secret);
359 try params.append("grant_type", "authorization_code");
360 try params.append("code", code);
361 try params.append("redirect_uri", try redirectUri(request_headers, alloc, T.callbackPath));
362 try params.append("state", "none");
363 const req_body = try params.encode();
364
365 var req = try http_client.request(.POST, try std.Uri.parse(client.provider.token_url), .{
366 .headers = .{
367 .accept_encoding = .{ .override = "identity" },
368 .authorization = .{ .override = try nio.fmt.allocPrint(alloc, "Basic {s}", .{try extras.base64EncodeAlloc(alloc, try std.mem.join(alloc, ":", &.{ client.id, client.secret }))}) },
369 .content_type = .{ .override = "application/x-www-form-urlencoded" },
370 },
371 .extra_headers = &.{
372 .{ .name = "Accept", .value = "application/json" },
373 },
374 .redirect_behavior = .not_allowed,
375 });
376 defer req.deinit();
377 try req.sendBodyComplete(req_body);
378 var resp = try req.receiveHead(&.{});
379 const body_content = try resp.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5));
380 if (resp.head.status != .ok) std.log.scoped(.oauth).debug("{s}: {s}", .{ @tagName(resp.head.status), body_content });
381 if (resp.head.status != .ok) return error.OauthBadToken;
382 const val = try json.parseFromSlice(alloc, "body.json", body_content, .{ .maximum_depth = 100, .support_trailing_commas = true });
383 val.acquire();
384 const tt = val.root.object().getS("token_type").?;
385 if (!std.ascii.eqlIgnoreCase(tt, "bearer")) return fail(response_status, body_writer, "oauth2: invalid token type: expected 'bearer', got '{s}'", .{tt});
386 const at = val.root.object().getS("access_token") orelse return try fail(response_status, body_writer, "Identity Provider Login Error!\n{s}", .{body_content});
387 val.release();
388
389 var req2 = try http_client.request(.GET, try std.Uri.parse(client.provider.me_url), .{
390 .headers = .{
391 .accept_encoding = .{ .override = "identity" },
392 .authorization = .{ .override = try nio.fmt.allocPrint(alloc, "Bearer {s}", .{at}) },
393 },
394 .extra_headers = &.{
395 .{ .name = "Accept", .value = "application/json" },
396 },
397 .redirect_behavior = .not_allowed,
398 });
399 defer req2.deinit();
400 try req2.sendBodiless();
401 var resp2 = try req2.receiveHead(&.{});
402 const body_content2 = try resp2.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5));
403 if (resp2.head.status != .ok) std.log.scoped(.oauth).debug("{s}: {s}", .{ @tagName(resp2.head.status), body_content2 });
404 if (resp2.head.status != .ok) return error.OauthBadUserinfo;
405 const val2 = try json.parseFromSlice(alloc, "body2.json", body_content2, .{ .maximum_depth = 100, .support_trailing_commas = true });
406 val2.acquire();
407 const id = try fixId(val2.root.object().getAny(client.provider.id_prop).?);
408 const name = val2.root.object().getS(client.provider.name_prop).?;
409 val2.release();
410 try T.saveInfo(response_headers, alloc, client.provider, id, name, val, val2);
411
412 try response_headers.append("location", T.doneUrl);
413 response_status.* = .found;
414 }
415 };
416}
417
418fn loginOne(request: *http.ServerRequest, alloc: std.mem.Allocator, comptime T: type, client: Client, callbackPath: string, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void {
419 if (try T.isLoggedIn(request, alloc)) {
420 try response_headers.append("location", T.doneUrl);
421 } else {
422 const idp = client.provider;
423 var params = url.SearchParams.init(alloc);
424 try params.append("client_id", client.id);
425 try params.append("redirect_uri", try redirectUri(request_headers, alloc, callbackPath));
426 try params.append("response_type", "code");
427 try params.append("scope", idp.scope);
428 try params.append("duration", "temporary");
429 try params.append("state", idp.id);
430 const authurl = try std.mem.join(alloc, "?", &.{ idp.authorize_url, try params.encode() });
431 try response_headers.append("location", authurl);
432 }
433 response_status.* = .found;
434}
435
436fn fail(response_status: *http.Status, body_writer: anytype, comptime err: string, args: anytype) !void {
437 response_status.* = .bad_request;
438 try body_writer.print(err, args);
439}
440
441fn redirectUri(request_headers: *const http.HeadersMap, alloc: std.mem.Allocator, callbackPath: string) !string {
442 const xproto = request_headers.find("x-forwarded-proto") orelse "";
443 const maybe_tls = std.mem.eql(u8, xproto, "https");
444 const proto: string = if (maybe_tls) "https" else "http";
445 const host = request_headers.find("host").?;
446 return try nio.fmt.allocPrint(alloc, "{s}://{s}{s}", .{ proto, host, callbackPath });
447}
448
449fn fixId(id: json.ValueIndex) !string {
450 return switch (id.v()) {
451 .string => |v| v.to(),
452 .number => |v| v.to(),
453 else => unreachable,
454 };
455}
456
457pub fn pek_domain(alloc: std.mem.Allocator, writer: pek.Writer, p: Provider) !void {
458 _ = alloc;
459 try writer.writeAll(p.domain());
460}