From cd1d0bb620f50d9d29e63c6e3cc9654d3bb5222a Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 15:59:03 +0900 Subject: [PATCH 1/7] project: add github to auth type definitions --- common/oko_types/src/auth/index.ts | 8 ++++++- .../src/social_login/social_login.ts | 22 +++++++++++++++++++ sdk/oko_sdk_core/src/types/oauth.ts | 18 +++++++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/common/oko_types/src/auth/index.ts b/common/oko_types/src/auth/index.ts index 66e51afaf..f1c350367 100644 --- a/common/oko_types/src/auth/index.ts +++ b/common/oko_types/src/auth/index.ts @@ -1,4 +1,10 @@ -export type AuthType = "google" | "auth0" | "x" | "telegram" | "discord"; +export type AuthType = + | "google" + | "auth0" + | "x" + | "telegram" + | "discord" + | "github"; export interface TokenResult { token: string; diff --git a/common/oko_types/src/social_login/social_login.ts b/common/oko_types/src/social_login/social_login.ts index 56cfd4451..761ebeba5 100644 --- a/common/oko_types/src/social_login/social_login.ts +++ b/common/oko_types/src/social_login/social_login.ts @@ -18,3 +18,25 @@ export type SocialLoginXVerifyUserResponse = { username: string; email?: string; }; + +export type SocialLoginGithubBody = { + code: string; + code_verifier: string; + redirect_uri: string; +}; + +export type SocialLoginGithubResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +}; + +export type SocialLoginGithubVerifyUserResponse = { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +}; diff --git a/sdk/oko_sdk_core/src/types/oauth.ts b/sdk/oko_sdk_core/src/types/oauth.ts index a710ef84f..572b9cf75 100644 --- a/sdk/oko_sdk_core/src/types/oauth.ts +++ b/sdk/oko_sdk_core/src/types/oauth.ts @@ -1,6 +1,12 @@ import type { AuthType } from "@oko-wallet/oko-types/auth"; -export type SignInType = "google" | "email" | "x" | "telegram" | "discord"; +export type SignInType = + | "google" + | "email" + | "x" + | "telegram" + | "discord" + | "github"; export type OAuthState = { apiKey: string; @@ -24,7 +30,8 @@ export interface OAuthPayload { export type OAuthTokenRequestPayload = | OAuthTokenRequestPayloadOfX | OAuthTokenRequestPayloadOfTelegram - | OAuthTokenRequestPayloadOfDiscord; + | OAuthTokenRequestPayloadOfDiscord + | OAuthTokenRequestPayloadOfGithub; export interface OAuthTokenRequestPayloadOfX { code: string; @@ -46,3 +53,10 @@ export interface OAuthTokenRequestPayloadOfTelegram { target_origin: string; auth_type: "telegram"; } + +export interface OAuthTokenRequestPayloadOfGithub { + code: string; + api_key: string; + target_origin: string; + auth_type: "github"; +} From dbf2f45cd6b686ed2dddec57671885b115fea297 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 16:14:48 +0900 Subject: [PATCH 2/7] oko_api: add github oauth token exchange proxy route --- apps/demo_web/src/types/login.ts | 3 +- backend/oko_api/server/src/api/github.ts | 40 +++++ .../social_login_v1/get_github_token.ts | 139 ++++++++++++++++++ .../src/routes/social_login_v1/index.ts | 7 + backend/openapi/src/social_login/github.ts | 93 ++++++++++++ backend/openapi/src/social_login/index.ts | 1 + .../oauth_info_pass/validate_social_login.ts | 9 +- .../src/ui/signin_modal/components/icons.tsx | 11 ++ .../signin_modal/components/progress_view.tsx | 2 + 9 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 backend/oko_api/server/src/api/github.ts create mode 100644 backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts create mode 100644 backend/openapi/src/social_login/github.ts diff --git a/apps/demo_web/src/types/login.ts b/apps/demo_web/src/types/login.ts index 35db5d75f..84c9ca81a 100644 --- a/apps/demo_web/src/types/login.ts +++ b/apps/demo_web/src/types/login.ts @@ -4,4 +4,5 @@ export type LoginMethod = | "x" | "discord" | "apple" - | "email"; + | "email" + | "github"; diff --git a/backend/oko_api/server/src/api/github.ts b/backend/oko_api/server/src/api/github.ts new file mode 100644 index 000000000..b868b5ef8 --- /dev/null +++ b/backend/oko_api/server/src/api/github.ts @@ -0,0 +1,40 @@ +import type { Result } from "@oko-wallet/stdlib-js"; +import type { SocialLoginGithubVerifyUserResponse } from "@oko-wallet/oko-types/social_login"; + +export const GITHUB_SOCIAL_LOGIN_TOKEN_URL = + "https://github.com/login/oauth/access_token"; +export const GITHUB_USER_INFO_URL = "https://api.github.com/user"; +export const GITHUB_CLIENT_ID = "PLACEHOLDER_GITHUB_CLIENT_ID"; + +export async function getGithubUserInfo(accessToken: string): Promise< + Result< + SocialLoginGithubVerifyUserResponse, + { + status: number; + text: string; + } + > +> { + const res = await fetch(GITHUB_USER_INFO_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + + if (res.ok) { + return { + success: true, + data: await res.json(), + }; + } + + return { + success: false, + err: { + status: res.status, + text: await res.text(), + }, + }; +} diff --git a/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts b/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts new file mode 100644 index 000000000..b369adc53 --- /dev/null +++ b/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts @@ -0,0 +1,139 @@ +import type { Response, Request } from "express"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import type { + SocialLoginGithubBody, + SocialLoginGithubResponse, +} from "@oko-wallet/oko-types/social_login"; +import { registry } from "@oko-wallet/oko-api-openapi"; +import { ErrorResponseSchema } from "@oko-wallet/oko-api-openapi/common"; +import { + SocialLoginGithubRequestSchema, + SocialLoginGithubSuccessResponseSchema, +} from "@oko-wallet/oko-api-openapi/social_login"; + +import { + GITHUB_CLIENT_ID, + GITHUB_SOCIAL_LOGIN_TOKEN_URL, +} from "@oko-wallet-api/api/github"; + +registry.registerPath({ + method: "post", + path: "/social-login/v1/github/get-token", + tags: ["Social Login"], + summary: "Get GitHub access token", + description: + "Exchange authorization code for a GitHub access token using PKCE", + request: { + body: { + required: true, + content: { + "application/json": { + schema: SocialLoginGithubRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully retrieved access token", + content: { + "application/json": { + schema: SocialLoginGithubSuccessResponseSchema, + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: "Server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +export async function getGithubToken( + req: Request, + res: Response>, +) { + const body = req.body; + + if (!body.code || !body.code_verifier || !body.redirect_uri) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "Code, code_verifier, or redirect_uri is not set", + }); + return; + } + + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + if (!clientSecret) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "GitHub client secret is not configured", + }); + return; + } + + try { + const reqBody = new URLSearchParams({ + code: body.code, + grant_type: "authorization_code", + client_id: GITHUB_CLIENT_ID, + client_secret: clientSecret, + redirect_uri: body.redirect_uri, + code_verifier: body.code_verifier, + }); + + const response = await fetch(GITHUB_SOCIAL_LOGIN_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: reqBody, + }); + + if (response.status === 200) { + const data = await response.json(); + + if (data.error) { + res.status(400).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `${data.error}: ${data.error_description}`, + }); + return; + } + + res.status(200).json({ + success: true, + data, + }); + return; + } + + res.status(response.status).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: await response.text(), + }); + } catch (err: any) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: err.message || "Failed to exchange GitHub token", + }); + } +} diff --git a/backend/oko_api/server/src/routes/social_login_v1/index.ts b/backend/oko_api/server/src/routes/social_login_v1/index.ts index d86b3bbd9..d9b38f48b 100644 --- a/backend/oko_api/server/src/routes/social_login_v1/index.ts +++ b/backend/oko_api/server/src/routes/social_login_v1/index.ts @@ -2,6 +2,7 @@ import { userJwtMiddleware } from "@oko-wallet-api/middleware/auth/keplr_auth"; import express from "express"; import { getXToken } from "./get_x_token"; +import { getGithubToken } from "./get_github_token"; import { verifyXUser } from "./verify_x_user"; import { rateLimitMiddleware } from "@oko-wallet-api/middleware/rate_limit"; import { saveReferral } from "./save_referral"; @@ -16,6 +17,12 @@ export function makeSocialLoginRouter() { getXToken, ); + router.post( + "/github/get-token", + rateLimitMiddleware({ windowSeconds: 60, maxRequests: 10 }), + getGithubToken, + ); + router.get( "/x/verify-user", rateLimitMiddleware({ windowSeconds: 60, maxRequests: 10 }), diff --git a/backend/openapi/src/social_login/github.ts b/backend/openapi/src/social_login/github.ts new file mode 100644 index 000000000..a435d8398 --- /dev/null +++ b/backend/openapi/src/social_login/github.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +import { registry } from "../registry"; + +export const GithubAuthHeaderSchema = z.object({ + Authorization: z.string().openapi({ + description: "GitHub access token as Bearer token", + example: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + param: { + name: "Authorization", + in: "header", + required: true, + }, + }), +}); + +export const SocialLoginGithubRequestSchema = registry.register( + "SocialLoginGithubRequest", + z.object({ + code: z.string().openapi({ + description: "Authorization code from GitHub", + }), + code_verifier: z.string().openapi({ + description: "PKCE code verifier", + }), + redirect_uri: z.string().openapi({ + description: "Redirect URI used in OAuth flow", + }), + }), +); + +const SocialLoginGithubResponseSchema = registry.register( + "SocialLoginGithubResponse", + z.object({ + access_token: z.string().openapi({ + description: "Access token issued by GitHub", + }), + refresh_token: z.string().optional().openapi({ + description: "Refresh token issued by GitHub (optional)", + }), + expires_in: z.number().int().optional().openapi({ + description: "Access token expiration time in seconds", + }), + token_type: z.string().optional().openapi({ + description: "Token type (typically 'bearer')", + }), + scope: z.string().optional().openapi({ + description: "Granted OAuth scopes", + }), + }), +); + +export const SocialLoginGithubSuccessResponseSchema = registry.register( + "SocialLoginGithubSuccessResponse", + z.object({ + success: z.literal(true).openapi({ + description: "Indicates the request succeeded", + }), + data: SocialLoginGithubResponseSchema, + }), +); + +const SocialLoginGithubVerifyUserResponseSchema = registry.register( + "SocialLoginGithubVerifyUserResponse", + z.object({ + id: z.number().int().openapi({ + description: "GitHub user ID", + }), + login: z.string().openapi({ + description: "GitHub username", + }), + name: z.string().nullable().openapi({ + description: "User display name", + }), + email: z.string().nullable().openapi({ + description: "User email address (if provided by GitHub)", + }), + avatar_url: z.string().openapi({ + description: "User avatar URL", + }), + }), +); + +export const SocialLoginGithubVerifyUserSuccessResponseSchema = + registry.register( + "SocialLoginGithubVerifyUserSuccessResponse", + z.object({ + success: z.literal(true).openapi({ + description: "Indicates the request succeeded", + }), + data: SocialLoginGithubVerifyUserResponseSchema, + }), + ); diff --git a/backend/openapi/src/social_login/index.ts b/backend/openapi/src/social_login/index.ts index e3779c8b9..a4fd4ab6c 100644 --- a/backend/openapi/src/social_login/index.ts +++ b/backend/openapi/src/social_login/index.ts @@ -1,2 +1,3 @@ export * from "./referral"; export * from "./x"; +export * from "./github"; diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts index b5f8f9508..5d910ceb4 100644 --- a/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts @@ -197,6 +197,12 @@ export async function getCredentialsFromPayload( return validateOAuthPayloadOfTelegram(payload); case "discord": return validateOAuthPayloadOfDiscord(payload, hostOrigin); + case "github": + // TODO(OKO-636): implement in Phase 6 + return { + success: false, + err: { type: "unknown", error: "GitHub login not yet implemented" }, + }; } } else { // payload is OAuthPayload @@ -222,6 +228,7 @@ function isOAuthTokenRequestPayload( return ( payload.auth_type === "x" || payload.auth_type === "telegram" || - payload.auth_type === "discord" + payload.auth_type === "discord" || + payload.auth_type === "github" ); } diff --git a/sdk/oko_sdk_core/src/ui/signin_modal/components/icons.tsx b/sdk/oko_sdk_core/src/ui/signin_modal/components/icons.tsx index dd1f36eab..41c32737a 100644 --- a/sdk/oko_sdk_core/src/ui/signin_modal/components/icons.tsx +++ b/sdk/oko_sdk_core/src/ui/signin_modal/components/icons.tsx @@ -123,6 +123,17 @@ export const DiscordIcon: FC = () => ( ); +export const GithubIcon: FC = () => ( + + + +); + export const AppleIcon: FC = () => ( = { x: XIcon, telegram: TelegramIcon, discord: DiscordIcon, + github: GithubIcon, }; export const ProgressView: FC = ({ From c65b698856667160ab0ce6c95ba7d16479111339 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 16:33:43 +0900 Subject: [PATCH 3/7] o --- .../social_login_v1/get_github_token.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts b/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts index b369adc53..e2b3b38fb 100644 --- a/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts +++ b/backend/oko_api/server/src/routes/social_login_v1/get_github_token.ts @@ -106,7 +106,10 @@ export async function getGithubToken( }); if (response.status === 200) { - const data = await response.json(); + const data: SocialLoginGithubResponse & { + error?: string; + error_description?: string; + } = await response.json(); if (data.error) { res.status(400).json({ @@ -119,7 +122,13 @@ export async function getGithubToken( res.status(200).json({ success: true, - data, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + token_type: data.token_type, + scope: data.scope, + }, }); return; } @@ -129,11 +138,13 @@ export async function getGithubToken( code: "UNKNOWN_ERROR", msg: await response.text(), }); - } catch (err: any) { + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to exchange GitHub token"; res.status(500).json({ success: false, code: "UNKNOWN_ERROR", - msg: err.message || "Failed to exchange GitHub token", + msg: message, }); } } From d74ad14abd758cb2387eca337684d88e54e43365 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 17:09:40 +0900 Subject: [PATCH 4/7] ks_node: add GitHub OAuth token validation --- .../src/middleware/auth/github_auth/index.ts | 65 +++++++++++++++++++ .../middleware/auth/github_auth/validate.ts | 34 ++++++++++ .../server/src/middleware/auth/oauth.ts | 12 +++- backend/openapi/src/tss/request.ts | 2 +- backend/openapi/src/tss/user.ts | 2 +- 5 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 backend/oko_api/server/src/middleware/auth/github_auth/index.ts create mode 100644 backend/oko_api/server/src/middleware/auth/github_auth/validate.ts diff --git a/backend/oko_api/server/src/middleware/auth/github_auth/index.ts b/backend/oko_api/server/src/middleware/auth/github_auth/index.ts new file mode 100644 index 000000000..232729576 --- /dev/null +++ b/backend/oko_api/server/src/middleware/auth/github_auth/index.ts @@ -0,0 +1,65 @@ +import type { Request, Response, NextFunction } from "express"; +import type { AuthType } from "@oko-wallet/oko-types/auth"; + +import { validateAccessTokenOfGithub } from "@oko-wallet-api/middleware/auth/github_auth/validate"; +import type { OAuthLocals } from "@oko-wallet-api/middleware/auth/types"; + +export interface GithubAuthenticatedRequest extends Request { + body: T; +} + +export async function githubAuthMiddleware( + req: GithubAuthenticatedRequest, + res: Response, + next: NextFunction, +) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res + .status(401) + .json({ error: "Authorization header with Bearer token required" }); + return; + } + + const accessToken = authHeader.substring(7).trim(); + + try { + const result = await validateAccessTokenOfGithub(accessToken); + + if (!result.success) { + res.status(401).json({ error: result.err }); + return; + } + + if (!result.data) { + res.status(500).json({ + error: "Internal server error: Token info missing after validation", + }); + return; + } + + if (result.data.id == null) { + res.status(401).json({ + error: "Can't get id from GitHub token", + }); + return; + } + + res.locals.oauth_user = { + type: "github" as AuthType, + user_identifier: `github_${result.data.id}`, + email: result.data.email ?? undefined, + name: result.data.login, + metadata: result.data as unknown as Record, + }; + + next(); + return; + } catch (err: unknown) { + res.status(500).json({ + error: `Token validation failed: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } +} diff --git a/backend/oko_api/server/src/middleware/auth/github_auth/validate.ts b/backend/oko_api/server/src/middleware/auth/github_auth/validate.ts new file mode 100644 index 000000000..db9b3723f --- /dev/null +++ b/backend/oko_api/server/src/middleware/auth/github_auth/validate.ts @@ -0,0 +1,34 @@ +import type { Result } from "@oko-wallet/stdlib-js"; +import type { SocialLoginGithubVerifyUserResponse } from "@oko-wallet/oko-types/social_login"; + +import { getGithubUserInfo } from "@oko-wallet-api/api/github"; + +export async function validateAccessTokenOfGithub( + accessToken: string, +): Promise> { + try { + const res = await getGithubUserInfo(accessToken); + if (!res.success) { + if (res.err.status === 429) { + return { + success: false, + err: "Too Many Requests", + }; + } + return { + success: false, + err: `Invalid token: ${res.err.text}`, + }; + } + + return { + success: true, + data: res.data, + }; + } catch (error: unknown) { + return { + success: false, + err: `Token validation failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/backend/oko_api/server/src/middleware/auth/oauth.ts b/backend/oko_api/server/src/middleware/auth/oauth.ts index 760df7364..2b3366e60 100644 --- a/backend/oko_api/server/src/middleware/auth/oauth.ts +++ b/backend/oko_api/server/src/middleware/auth/oauth.ts @@ -21,6 +21,10 @@ import { discordAuthMiddleware, type DiscordAuthenticatedRequest, } from "@oko-wallet-api/middleware/auth/discord_auth"; +import { + githubAuthMiddleware, + type GithubAuthenticatedRequest, +} from "@oko-wallet-api/middleware/auth/github_auth"; import type { OAuthBody, OAuthLocals, @@ -59,9 +63,15 @@ export async function oauthMiddleware( res, next, ); + case "github": + return githubAuthMiddleware( + req as GithubAuthenticatedRequest, + res, + next, + ); default: res.status(400).json({ - error: `Invalid auth_type: ${authType}. Must be 'google', 'auth0', 'x', or 'telegram'`, + error: `Invalid auth_type: ${authType}. Must be 'google', 'auth0', 'x', 'telegram', 'discord', or 'github'`, }); return; } diff --git a/backend/openapi/src/tss/request.ts b/backend/openapi/src/tss/request.ts index fc1f71470..30dac24e4 100644 --- a/backend/openapi/src/tss/request.ts +++ b/backend/openapi/src/tss/request.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { registry } from "../registry"; import { CommitRevealRequestFieldsSchema } from "./commit_reveal"; -const OAuthTypeSchema = z.enum(["google", "auth0", "x", "telegram", "discord"]).openapi({ +const OAuthTypeSchema = z.enum(["google", "auth0", "x", "telegram", "discord", "github"]).openapi({ description: "OAuth provider type", example: "google", }); diff --git a/backend/openapi/src/tss/user.ts b/backend/openapi/src/tss/user.ts index 7bf539289..0ef0e8ac6 100644 --- a/backend/openapi/src/tss/user.ts +++ b/backend/openapi/src/tss/user.ts @@ -106,7 +106,7 @@ export const SignInSuccessResponseV2Schema = registry.register( }), ); -const AuthTypeEnum = z.enum(["google", "auth0", "x", "telegram", "discord"]); +const AuthTypeEnum = z.enum(["google", "auth0", "x", "telegram", "discord", "github"]); export const CheckEmailRequestSchema = registry.register( "TssUserCheckEmailRequest", From 02b2314ae3fd12937c365fb69dbceb3e6ef35867 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 17:35:09 +0900 Subject: [PATCH 5/7] ks_node: add GitHub OAuth token validation --- .../server/src/auth/github/index.ts | 61 +++++++++++++++++++ key_share_node/server/src/auth/index.ts | 1 + key_share_node/server/src/middlewares/auth.ts | 28 +++++++++ .../server/src/openapi/schema/key_share_v1.ts | 2 +- 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 key_share_node/server/src/auth/github/index.ts diff --git a/key_share_node/server/src/auth/github/index.ts b/key_share_node/server/src/auth/github/index.ts new file mode 100644 index 000000000..b092a6520 --- /dev/null +++ b/key_share_node/server/src/auth/github/index.ts @@ -0,0 +1,61 @@ +import type { Result } from "@oko-wallet/stdlib-js"; + +import type { OAuthValidationFail } from "../types"; + +export const GITHUB_USER_INFO_URL = "https://api.github.com/user"; + +export interface GithubUserInfo { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + +export async function validateGithubOAuthToken( + accessToken: string, +): Promise> { + try { + const res = await fetch(GITHUB_USER_INFO_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + + if (!res.ok) { + if (res.status === 429) { + return { + success: false, + err: { + type: "unknown", + message: "Too Many Requests", + }, + }; + } + return { + success: false, + err: { + type: "invalid_token", + message: "Invalid or malformed token", + }, + }; + } + + const userInfo: GithubUserInfo = await res.json(); + + return { + success: true, + data: userInfo, + }; + } catch (err: unknown) { + return { + success: false, + err: { + type: "unknown", + message: `Token validation failed: ${err instanceof Error ? err.message : String(err)}`, + }, + }; + } +} diff --git a/key_share_node/server/src/auth/index.ts b/key_share_node/server/src/auth/index.ts index 5301e74b9..2521321e8 100644 --- a/key_share_node/server/src/auth/index.ts +++ b/key_share_node/server/src/auth/index.ts @@ -2,4 +2,5 @@ export { validateGoogleOAuthToken } from "./google"; export { validateAuth0Token } from "./auth0"; export { validateTelegramHash } from "./telegram"; export { validateDiscordOAuthToken } from "./discord"; +export { validateGithubOAuthToken } from "./github"; export type { OAuthUser, OAuthValidationFail } from "./types"; diff --git a/key_share_node/server/src/middlewares/auth.ts b/key_share_node/server/src/middlewares/auth.ts index 58274711a..4d58a97b4 100644 --- a/key_share_node/server/src/middlewares/auth.ts +++ b/key_share_node/server/src/middlewares/auth.ts @@ -11,6 +11,7 @@ import type { OAuthValidationFail } from "@oko-wallet-ksn-server/auth/types"; import { validateAuth0Token, validateDiscordOAuthToken, + validateGithubOAuthToken, validateGoogleOAuthToken, validateTelegramHash, } from "@oko-wallet-ksn-server/auth"; @@ -19,6 +20,7 @@ import type { ResponseLocal } from "@oko-wallet-ksn-server/routes/io"; import { validateAccessTokenOfX } from "@oko-wallet-ksn-server/auth/x"; import type { Auth0TokenInfo } from "@oko-wallet-ksn-server/auth/auth0"; import type { XUserInfo } from "@oko-wallet-ksn-server/auth/x"; +import type { GithubUserInfo } from "@oko-wallet-ksn-server/auth/github"; import type { TelegramUserData, TelegramUserInfo, @@ -48,6 +50,10 @@ type VerifyResult = | { auth_type: "discord"; data: Result; + } + | { + auth_type: "github"; + data: Result; }; export interface AuthenticatedRequest @@ -122,6 +128,12 @@ export async function bearerTokenMiddleware( data: await validateDiscordOAuthToken(bearerToken), }; break; + case "github": + result = { + auth_type: "github", + data: await validateGithubOAuthToken(bearerToken), + }; + break; default: { const errorRes: KSNodeApiErrorResponse = { @@ -225,6 +237,22 @@ export async function bearerTokenMiddleware( }; break; } + case "github": { + if (result.data.data.id == null) { + const errorRes: KSNodeApiErrorResponse = { + success: false, + code: "UNAUTHORIZED", + msg: "Invalid token: missing required field (id)", + }; + res.status(ErrorCodeMap[errorRes.code]).json(errorRes); + return; + } + res.locals.oauth_user = { + type: result.auth_type, + user_identifier: `github_${result.data.data.id}`, + }; + break; + } } next(); diff --git a/key_share_node/server/src/openapi/schema/key_share_v1.ts b/key_share_node/server/src/openapi/schema/key_share_v1.ts index 1412494d8..40a960a24 100644 --- a/key_share_node/server/src/openapi/schema/key_share_v1.ts +++ b/key_share_node/server/src/openapi/schema/key_share_v1.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { registry } from "../doc"; export const authTypeSchema = z - .enum(["google", "auth0", "x", "telegram", "discord"]) + .enum(["google", "auth0", "x", "telegram", "discord", "github"]) .describe("Authentication provider type") .openapi({ example: "google" }); From c4ac7b5b6cf0bba7d1dab53c8837b3f87481b328 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 18:18:48 +0900 Subject: [PATCH 6/7] sdk_core: add GitHub sign in method --- sdk/oko_sdk_core/src/auth/github.ts | 1 + .../src/methods/sign_in/github.ts | 188 ++++++++++++++++++ sdk/oko_sdk_core/src/methods/sign_in/index.ts | 4 + 3 files changed, 193 insertions(+) create mode 100644 sdk/oko_sdk_core/src/auth/github.ts create mode 100644 sdk/oko_sdk_core/src/methods/sign_in/github.ts diff --git a/sdk/oko_sdk_core/src/auth/github.ts b/sdk/oko_sdk_core/src/auth/github.ts new file mode 100644 index 000000000..1ff4d4500 --- /dev/null +++ b/sdk/oko_sdk_core/src/auth/github.ts @@ -0,0 +1 @@ +export const GITHUB_CLIENT_ID = "PLACEHOLDER_GITHUB_CLIENT_ID"; diff --git a/sdk/oko_sdk_core/src/methods/sign_in/github.ts b/sdk/oko_sdk_core/src/methods/sign_in/github.ts new file mode 100644 index 000000000..89188b760 --- /dev/null +++ b/sdk/oko_sdk_core/src/methods/sign_in/github.ts @@ -0,0 +1,188 @@ +import type { + OAuthState, + OkoWalletInterface, + OkoWalletMsg, + OkoWalletMsgOAuthSignInUpdate, + OkoWalletMsgOAuthSignInUpdateAck, +} from "@oko-wallet-sdk-core/types"; +import { RedirectUriSearchParamsKey } from "@oko-wallet-sdk-core/types/oauth"; +import { GITHUB_CLIENT_ID } from "@oko-wallet-sdk-core/auth/github"; +import { createPkcePair } from "./utils"; + +const FIVE_MINS_MS = 5 * 60 * 1000; +const GITHUB_SCOPES = "user:email"; + +export async function handleGithubSignIn(okoWallet: OkoWalletInterface) { + const signInRes = await tryGithubSignIn( + okoWallet.sdkEndpoint, + okoWallet.apiKey, + okoWallet.sendMsgToIframe.bind(okoWallet), + ); + + if (!signInRes.payload.success) { + throw new Error(`sign in fail, err: ${signInRes.payload.err}`); + } +} + +function tryGithubSignIn( + sdkEndpoint: string, + apiKey: string, + sendMsgToIframe: (msg: OkoWalletMsg) => Promise, +): Promise { + const clientId = GITHUB_CLIENT_ID; + if (!clientId) { + throw new Error("GITHUB_CLIENT_ID is not set"); + } + + const redirectUri = `${new URL(sdkEndpoint).origin}/github/callback`; + + console.debug( + "[oko] GitHub login - window host: %s", + window.location.host, + ); + console.debug("[oko] GitHub login - redirectUri: %s", redirectUri); + + const oauthState: OAuthState = { + apiKey, + targetOrigin: window.location.origin, + provider: "github", + }; + + const oauthStateString = btoa(JSON.stringify(oauthState)); + + console.debug( + "[oko] GitHub login - oauthStateString: %s", + oauthStateString, + ); + + const popup = window.open( + "about:blank", + "github_oauth", + "width=1200,height=800", + ); + + if (!popup) { + throw new Error( + "Failed to open new window for GitHub oauth sign in", + ); + } + + return new Promise( + async (resolve, reject) => { + const { codeVerifier, codeChallenge } = await createPkcePair(); + + const codeVerifierAckPromise = sendMsgToIframe({ + target: "oko_attached", + msg_type: "set_code_verifier", + payload: codeVerifier, + }); + + const authUrl = new URL( + "https://github.com/login/oauth/authorize", + ); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", GITHUB_SCOPES); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set( + RedirectUriSearchParamsKey.STATE, + oauthStateString, + ); + + try { + popup.location.href = authUrl.toString(); + } catch (error) { + popup.close(); + const errorMessage = + error instanceof Error + ? error.message + : String(error); + throw new Error( + `Failed to redirect popup to auth URL: ${errorMessage}`, + ); + } + + const ack = await codeVerifierAckPromise; + + if ( + ack.msg_type !== "set_code_verifier_ack" || + !ack.payload.success + ) { + throw new Error( + "Failed to set code verifier for GitHub oauth sign in", + ); + } + + let popupTimeoutTimer: number; + let popupCloseCheckTimer: number; + + function onMessage(event: MessageEvent) { + if (event.ports.length < 1) { + return; + } + + const port = event.ports[0]; + const data = event.data as OkoWalletMsg; + + if (data.msg_type === "oauth_sign_in_update") { + console.debug( + "[oko] GitHub login - oauth_sign_in_update recv, %o", + data, + ); + + const msg: OkoWalletMsgOAuthSignInUpdateAck = { + target: "oko_attached", + msg_type: "oauth_sign_in_update_ack", + payload: null, + }; + + port.postMessage(msg); + + if (data.payload.success) { + resolve(data); + } else { + reject(new Error(data.payload.err.type)); + } + + cleanup(); + } + } + + window.addEventListener("message", onMessage); + + popupCloseCheckTimer = window.setInterval(() => { + if (popup.closed) { + console.debug( + "[oko] Popup was closed by user, rejecting sign-in", + ); + cleanup(); + reject(new Error("Sign-in cancelled")); + } + }, 500); + + popupTimeoutTimer = window.setTimeout(() => { + cleanup(); + reject( + new Error("Timeout: no response within 5 minutes"), + ); + closePopup(popup); + }, FIVE_MINS_MS); + + function cleanup() { + console.debug( + "[oko] GitHub login - clean up oauth sign in listener", + ); + window.clearTimeout(popupTimeoutTimer); + window.clearInterval(popupCloseCheckTimer); + window.removeEventListener("message", onMessage); + } + }, + ); +} + +function closePopup(popup: Window) { + if (popup && !popup.closed) { + popup.close(); + } +} diff --git a/sdk/oko_sdk_core/src/methods/sign_in/index.ts b/sdk/oko_sdk_core/src/methods/sign_in/index.ts index 9baf573cd..8f9677894 100644 --- a/sdk/oko_sdk_core/src/methods/sign_in/index.ts +++ b/sdk/oko_sdk_core/src/methods/sign_in/index.ts @@ -5,6 +5,7 @@ import { handleEmailSignIn } from "./email"; import { handleXSignIn } from "./x"; import { handleTelegramSignIn } from "./telegram"; import { handleDiscordSignIn } from "./discord"; +import { handleGithubSignIn } from "./github"; export async function signIn(this: OkoWalletInterface, type: SignInType) { await this.waitUntilInitialized; @@ -26,6 +27,9 @@ export async function signIn(this: OkoWalletInterface, type: SignInType) { case "discord": await handleDiscordSignIn(this); break; + case "github": + await handleGithubSignIn(this); + break; default: throw new Error(`not supported sign in type, type: ${type}`); } From c8d4c37474d4dd87ce2d3356a99137796cdb9652 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 25 Feb 2026 19:37:45 +0900 Subject: [PATCH 7/7] oko_attached: add GitHub callback and token exchange --- .../github_callback/github_callback.tsx | 89 +++++++++++++++ .../src/components/github_callback/types.ts | 6 ++ .../github_callback/use_callback.tsx | 102 ++++++++++++++++++ .../src/routes/github/callback/index.tsx | 7 ++ .../src/window_msgs/oauth_info_pass/github.ts | 87 +++++++++++++++ .../src/window_msgs/oauth_info_pass/token.ts | 20 ++++ .../oauth_info_pass/validate_social_login.ts | 57 +++++++++- embed/oko_attached/src/window_msgs/types.ts | 8 ++ 8 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 embed/oko_attached/src/components/github_callback/github_callback.tsx create mode 100644 embed/oko_attached/src/components/github_callback/types.ts create mode 100644 embed/oko_attached/src/components/github_callback/use_callback.tsx create mode 100644 embed/oko_attached/src/routes/github/callback/index.tsx create mode 100644 embed/oko_attached/src/window_msgs/oauth_info_pass/github.ts diff --git a/embed/oko_attached/src/components/github_callback/github_callback.tsx b/embed/oko_attached/src/components/github_callback/github_callback.tsx new file mode 100644 index 000000000..ea3c49e76 --- /dev/null +++ b/embed/oko_attached/src/components/github_callback/github_callback.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { type FC } from "react"; +import { LoadingIcon } from "@oko-wallet/oko-common-ui/icons/loading"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { OkoLogoIcon } from "@oko-wallet-common-ui/icons/oko_logo_icon"; +import { ErrorIcon } from "@oko-wallet/oko-common-ui/icons/error_icon"; +import { ExternalLinkOutlinedIcon } from "@oko-wallet/oko-common-ui/icons/external_link_outlined"; + +import styles from "@oko-wallet-attached/components/google_callback/google_callback.module.scss"; +import { useGithubCallback } from "@oko-wallet-attached/components/github_callback/use_callback"; +import { useSetThemeInCallback } from "@oko-wallet-attached/hooks/theme"; + +export const GithubCallback: FC = () => { + const theme = useSetThemeInCallback("github"); + const { error } = useGithubCallback(); + + return ( +
+ + + {error ? ( + + ) : ( + <> + + + + + Redirecting... + + + )} + +
+ ); +}; + +const ErrorMessage: FC<{ error: string }> = ({ error }) => { + const errorCode = error || "unknown_error"; + const isParamsNotSufficient = errorCode === "params_not_sufficient"; + const errorMessage = isParamsNotSufficient + ? `Error Code: ${errorCode}\n\nFailed to get the required information from GitHub. Please contact Oko for support.` + : `Error Code: ${errorCode}`; + + return ( + <> +
+
+
+ +
+ + + + + Having problems? + + + Get support + + + + + +
+ + {errorMessage} + +
+ + ); +}; diff --git a/embed/oko_attached/src/components/github_callback/types.ts b/embed/oko_attached/src/components/github_callback/types.ts new file mode 100644 index 000000000..17639196b --- /dev/null +++ b/embed/oko_attached/src/components/github_callback/types.ts @@ -0,0 +1,6 @@ +export type HandleGithubCallbackError = + | { type: "msg_pass_fail"; error: string } + | { type: "opener_window_not_exists" } + | { type: "params_not_sufficient" } + | { type: "login_canceled_by_user" } + | { type: "wrong_ack_type"; msg_type: string }; diff --git a/embed/oko_attached/src/components/github_callback/use_callback.tsx b/embed/oko_attached/src/components/github_callback/use_callback.tsx new file mode 100644 index 000000000..6beef850b --- /dev/null +++ b/embed/oko_attached/src/components/github_callback/use_callback.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import type { Result } from "@oko-wallet/stdlib-js"; +import { + RedirectUriSearchParamsKey, + type OAuthTokenRequestPayload, +} from "@oko-wallet/oko-sdk-core"; + +import type { HandleGithubCallbackError } from "./types"; +import { postLog } from "@oko-wallet-attached/requests/logging"; +import { errorToLog } from "@oko-wallet-attached/logging/error"; +import { sendOAuthPayloadToEmbeddedWindow } from "@oko-wallet-attached/components/oauth_callback/send_oauth_payload"; + +export function useGithubCallback() { + const [error, setError] = useState(null); + + useEffect(() => { + async function fn() { + try { + const cbRes = await handleGithubCallback(); + + if (cbRes.success) { + window.close(); + } else { + if (cbRes.err.type === "login_canceled_by_user") { + window.close(); + } + setError(cbRes.err.type); + } + } catch (err) { + postLog({ + level: "error", + message: "GitHub callback error", + error: errorToLog(err), + }); + + setError(err instanceof Error ? err.message : "Unknown error"); + } + } + + fn().then(); + }, []); + + return { error }; +} + +export async function handleGithubCallback(): Promise< + Result +> { + if (!window.opener) { + return { + success: false, + err: { + type: "opener_window_not_exists", + }, + }; + } + + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get("code"); + const stateParam = urlParams.get(RedirectUriSearchParamsKey.STATE) || "{}"; + + if (!code) { + return { + success: false, + err: { type: "login_canceled_by_user" }, + }; + } + + if (!stateParam) { + return { + success: false, + err: { type: "params_not_sufficient" }, + }; + } + + const oauthState = JSON.parse(atob(stateParam)); + const apiKey: string = oauthState.apiKey; + const targetOrigin: string = oauthState.targetOrigin; + + if (!apiKey || !targetOrigin) { + return { + success: false, + err: { type: "params_not_sufficient" }, + }; + } + + const payload: OAuthTokenRequestPayload = { + code: code, + api_key: apiKey, + target_origin: targetOrigin, + auth_type: "github", + }; + + const sendRes = await sendOAuthPayloadToEmbeddedWindow(payload); + + if (!sendRes.success) { + console.error("[attached] send oauth result fail, err: %o", sendRes.err); + return sendRes; + } + + return { success: true, data: void 0 }; +} diff --git a/embed/oko_attached/src/routes/github/callback/index.tsx b/embed/oko_attached/src/routes/github/callback/index.tsx new file mode 100644 index 000000000..191836549 --- /dev/null +++ b/embed/oko_attached/src/routes/github/callback/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GithubCallback } from "@oko-wallet-attached/components/github_callback/github_callback"; + +export const Route = createFileRoute("/github/callback/")({ + component: GithubCallback, +}); diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/github.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/github.ts new file mode 100644 index 000000000..a36f378d5 --- /dev/null +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/github.ts @@ -0,0 +1,87 @@ +import type { Result } from "@oko-wallet/stdlib-js"; + +import type { GithubUserInfo } from "@oko-wallet-attached/window_msgs/types"; +import { OKO_API_ENDPOINT } from "@oko-wallet-attached/requests/endpoints"; + +export async function getAccessTokenOfGithub( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise> { + try { + const response = await fetch( + `${OKO_API_ENDPOINT}/social-login/v1/github/get-token`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + err: `Token exchange failed: ${response.status} ${errorText}`, + }; + } + + const result = await response.json(); + if (!result.success) { + return { + success: false, + err: result.msg || "Token exchange failed", + }; + } + + return { success: true, data: result.data.access_token }; + } catch (err: unknown) { + return { + success: false, + err: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function verifyIdTokenOfGithub( + accessToken: string, +): Promise> { + try { + const response = await fetch("https://api.github.com/user", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + err: `Failed to get user info: ${response.status} ${errorText}`, + }; + } + + const result = (await response.json()) as GithubUserInfo; + if (result.id == null) { + return { + success: false, + err: "GitHub id not found", + }; + } + + return { success: true, data: result }; + } catch (err: unknown) { + return { + success: false, + err: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/token.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/token.ts index e6eb12044..badc2df12 100644 --- a/embed/oko_attached/src/window_msgs/oauth_info_pass/token.ts +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/token.ts @@ -11,6 +11,7 @@ import { AUTH0_DOMAIN, } from "@oko-wallet-attached/config/auth0"; import { verifyIdTokenOfDiscord } from "./discord"; +import { verifyIdTokenOfGithub } from "./github"; import { verifyIdTokenOfX } from "./x"; export async function verifyIdToken( @@ -103,6 +104,25 @@ export async function verifyIdToken( }; } + if (authType === "github") { + const githubTokenInfo = await verifyIdTokenOfGithub(idToken); + + if (!githubTokenInfo.success) { + return { + success: false, + err: githubTokenInfo.err, + }; + } + + return { + success: true, + data: { + provider: "github", + user_identifier: `github_${githubTokenInfo.data.id}`, + }, + }; + } + return { success: false, err: `Invalid authentication type: ${authType}`, diff --git a/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts b/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts index 5d910ceb4..9f29fda16 100644 --- a/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts +++ b/embed/oko_attached/src/window_msgs/oauth_info_pass/validate_social_login.ts @@ -5,12 +5,14 @@ import type { OAuthTokenRequestPayloadOfTelegram, OAuthTokenRequestPayloadOfX, OAuthTokenRequestPayloadOfDiscord, + OAuthTokenRequestPayloadOfGithub, } from "@oko-wallet/oko-sdk-core"; import type { Result } from "@oko-wallet/stdlib-js"; import { verifyIdToken } from "./token"; import { getAccessTokenOfX } from "./x"; import { getAccessTokenOfDiscordWithPKCE } from "./discord"; +import { getAccessTokenOfGithub } from "./github"; import { useAppState } from "@oko-wallet-attached/store/app"; type OAuthCredentialResult = Result< @@ -148,6 +150,55 @@ async function validateOAuthPayloadOfDiscord( }; } +async function validateOAuthPayloadOfGithub( + payload: OAuthTokenRequestPayloadOfGithub, + hostOrigin: string, +): Promise { + const appState = useAppState.getState(); + const codeVerifierRegistered = appState.getCodeVerifier(hostOrigin); + if (!codeVerifierRegistered) { + return { + success: false, + err: { type: "PKCE_missing" }, + }; + } + + const redirectUri = `${window.location.origin}/github/callback`; + + const tokenRes = await getAccessTokenOfGithub( + payload.code, + codeVerifierRegistered, + redirectUri, + ); + + if (!tokenRes.success) { + return { + success: false, + err: { type: "unknown", error: tokenRes.err }, + }; + } + + const verifyIdTokenRes = await verifyIdToken("github", tokenRes.data); + if (!verifyIdTokenRes.success) { + return { + success: false, + err: { type: "unknown", error: verifyIdTokenRes.err }, + }; + } + + const userInfo = verifyIdTokenRes.data; + + appState.setCodeVerifier(hostOrigin, null); + + return { + success: true, + data: { + idToken: tokenRes.data, + userIdentifier: userInfo.user_identifier, + }, + }; +} + async function validateOAuthPayload( payload: OAuthPayload, hostOrigin: string, @@ -198,11 +249,7 @@ export async function getCredentialsFromPayload( case "discord": return validateOAuthPayloadOfDiscord(payload, hostOrigin); case "github": - // TODO(OKO-636): implement in Phase 6 - return { - success: false, - err: { type: "unknown", error: "GitHub login not yet implemented" }, - }; + return validateOAuthPayloadOfGithub(payload, hostOrigin); } } else { // payload is OAuthPayload diff --git a/embed/oko_attached/src/window_msgs/types.ts b/embed/oko_attached/src/window_msgs/types.ts index ee03f4313..c62526c84 100644 --- a/embed/oko_attached/src/window_msgs/types.ts +++ b/embed/oko_attached/src/window_msgs/types.ts @@ -35,6 +35,14 @@ export interface XUserInfo { email?: string; } +export interface GithubUserInfo { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + export interface DiscordUserInfo { id: string; username: string;