diff --git a/.auri/$72f6ffrt.md b/.auri/$72f6ffrt.md new file mode 100644 index 000000000..87eda87f9 --- /dev/null +++ b/.auri/$72f6ffrt.md @@ -0,0 +1,6 @@ +--- +package: "@lucia-auth/oauth" # package name +type: "minor" # "major", "minor", "patch" +--- + +Add osu! OAuth provider \ No newline at end of file diff --git a/documentation-v2/content/oauth/providers/osu.md b/documentation-v2/content/oauth/providers/osu.md new file mode 100644 index 000000000..96e0e04ca --- /dev/null +++ b/documentation-v2/content/oauth/providers/osu.md @@ -0,0 +1,282 @@ +--- +order: 0 +title: "osu!" +description: "Learn about using the osu! provider in Lucia OAuth integration" +--- + +OAuth integration for osu!. Refer to [osu! OAuth documentation](https://osu.ppy.sh/docs/index.html#authentication) for getting the required credentials. Provider id is `osu`. + +```ts +import { osu } from "@lucia-auth/oauth/providers"; +import { auth } from "./lucia.js"; + +const osuAuth = osu(auth, config); +``` + +## `osu()` + +```ts +const osu: ( + auth: Auth, + configs: { + clientId: string; + clientSecret: string; + redirectUri: string; + scope?: string[]; + } +) => OsuProvider; +``` + +##### Parameters + +| name | type | description | optional | +| ---------------------- | ------------------------------------------ | ----------------------------------- | :------: | +| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | +| `configs.clientId` | `string` | osu! OAuth app client id | | +| `configs.clientSecret` | `string` | osu! OAuth app client secret | | +| `configs.redirectUri` | `string` | one of the authorized redirect URIs | | +| `configs.scope` | `string[]` | an array of scopes | ✓ | + +##### Returns + +| type | description | +| ----------------------------- | ------------- | +| [`OsuProvider`](#osuprovider) | osu! provider | + +## Interfaces + +### `OsuProvider` + +Satisfies [`OAuthProvider`](/reference/oauth/interfaces#oauthprovider). + +#### `getAuthorizationUrl()` + +Returns the authorization url for user redirection and a state for storage. The state should be stored in a cookie and validated on callback. + +```ts +const getAuthorizationUrl: () => Promise<[url: URL, state: string]>; +``` + +##### Returns + +| name | type | description | +| ------- | -------- | -------------------- | +| `url` | `URL` | authorize url | +| `state` | `string` | state parameter used | + +#### `validateCallback()` + +Validates the callback code. + +```ts +const validateCallback: (code: string) => Promise; +``` + +##### Parameters + +| name | type | description | +| ------ | -------- | ------------------------------------ | +| `code` | `string` | The authorization code from callback | + +##### Returns + +| type | +| ----------------------------- | +| [`OsuUserAuth`](#osuuserauth) | + +##### Errors + +Request errors are thrown as [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror). + +### `OsuUserAuth` + +```ts +type OsuUserAuth = ProviderUserAuth & { + osuUser: OsuUser; + osuTokens: OsuTokens; +}; +``` + +| type | +| ------------------------------------------------------------------ | +| [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) | +| [`OsuUser`](#osuuser) | +| [`OsuTokens`](#osutokens) | + +```ts +import type { OsuTokens, OsuUser } from "@lucia-auth/oauth/providers"; +``` + +### `OsuTokens` + +```ts +type OsuTokens = { + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; +}; +``` + +### `OsuUser` + +```ts +type OsuUser = { + avatar_url: string; + country_code: string; + default_group: string; + id: number; + is_active: boolean; + is_bot: boolean; + is_deleted: boolean; + is_online: boolean; + is_supporter: boolean; + last_visit: string; + pm_friends_only: boolean; + profile_colour: string | null; + username: string; + country: { + code: string; + name: string; + }; + cover: { + custom_url: string | null; + url: string; + id: string | null; + }; + discord: string | null; + has_supported: boolean; + interests: string | null; + join_date: string; + kudosu: { + available: number; + total: number; + }; + location: string | null; + max_blocks: number; + max_friends: number; + occupation: string | null; + playmode: OsuGameMode; + playstyle: ("mouse" | "keyboard" | "tablet" | "touch")[]; + post_count: number; + profile_order: ( + | "me" + | "recent_activity" + | "beatmaps" + | "historical" + | "kudosu" + | "top_ranks" + | "medals" + )[]; + title: string | null; + title_url: string | null; + twitter: string | null; + website: string | null; + is_restricted: boolean; + account_history: { + description: string | null; + id: number; + length: number; + permanent: boolean; + timestamp: string; + type: "note" | "restriction" | "silence"; + }[]; + active_tournament_banner: { + id: number; + tournament_id: number; + image: string; + } | null; + badges: { + awarded_at: string; + description: string; + image_url: string; + url: string; + }[]; + beatmap_playcounts_count: number; + favourite_beatmapset_count: number; + follower_count: number; + graveyard_beatmapset_count: number; + groups: { + colour: string | null; + has_listing: boolean; + has_playmodes: boolean; + id: number; + identifier: string; + is_probationary: boolean; + name: string; + short_name: string; + playmodes: OsuGameMode[] | null; + }[]; + loved_beatmapset_count: number; + mapping_follower_count: number; + monthly_playcounts: { + start_date: string; + count: number; + }[]; + page: { + html: string; + raw: string; + }; + pending_beatmapset_count: number; + previous_usernames: string[]; + rank_highest: { + rank: number; + updated_at: string; + } | null; + rank_history: { + mode: OsuGameMode; + data: number[]; + }; + ranked_beatmapset_count: number; + replays_watched_counts: { + start_date: string; + count: number; + }[]; + scores_best_count: number; + scores_first_count: number; + scores_recent_count: number; + statistics: OsuUserStatistics; + statistics_rulesets: Record; + support_level: number; + user_achievements: { + achieved_at: string; + achievement_id: number; + }[]; +}; +``` + +### `OsuGameMode` + +```ts +type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; +``` + +### `OsuUserStatistics` + +```ts +type OsuUserStatistics = { + grade_counts: { + a: number; + s: number; + sh: number; + ss: number; + ssh: number; + }; + hit_accuracy: number; + is_ranked: boolean; + level: { + current: number; + progress: number; + }; + maximum_combo: number; + play_count: number; + play_time: number; + pp: number; + global_rank: number; + ranked_score: number; + replays_watched_by_others: number; + total_hits: number; + total_score: number; + country_rank: number; +}; +``` diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 12a1eeb19..29c280901 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -9,3 +9,4 @@ export { linkedin, type LinkedinUser } from "./linkedin.js"; export { auth0, type Auth0User } from "./auth0.js"; export { facebook, type FacebookUser } from "./facebook.js"; export { spotify, type SpotifyUser } from "./spotify.js"; +export { osu, type OsuUser } from "./osu.js"; diff --git a/packages/oauth/src/providers/osu.ts b/packages/oauth/src/providers/osu.ts new file mode 100644 index 000000000..2fb8e144a --- /dev/null +++ b/packages/oauth/src/providers/osu.ts @@ -0,0 +1,230 @@ +import { createUrl, handleRequest, authorizationHeaders } from "../request.js"; +import { providerUserAuth } from "../core.js"; +import { scope, generateState } from "../utils.js"; + +import type { Auth } from "lucia"; +import type { OAuthConfig, OAuthProvider } from "../core.js"; + +type Config = OAuthConfig & { + redirectUri: string; +}; + +const PROVIDER_ID = "osu"; + +export const osu = <_Auth extends Auth>(auth: _Auth, config: Config) => { + const getOsuTokens = async (code: string) => { + const request = new Request("https://osu.ppy.sh/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "authorization_code", + redirect_uri: config.redirectUri, + code + }).toString() + }); + const tokens = await handleRequest<{ + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; + }>(request); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + accessTokenExpiresIn: tokens.expires_in + }; + }; + + const getOsuUser = async (accessToken: string) => { + const request = new Request("https://osu.ppy.sh/api/v2/me/osu", { + headers: authorizationHeaders("bearer", accessToken) + }); + const osuUser = await handleRequest(request); + return osuUser; + }; + + return { + getAuthorizationUrl: async () => { + const state = generateState(); + const url = createUrl("https://osu.ppy.sh/oauth/authorize", { + response_type: "code", + client_id: config.clientId, + scope: scope(["identify"], config.scope), + redirect_uri: config.redirectUri, + state + }); + return [url, state]; + }, + validateCallback: async (code: string) => { + const osuTokens = await getOsuTokens(code); + const osuUser = await getOsuUser(osuTokens.accessToken); + const providerUserId = osuUser.id.toString(); + const osuUserAuth = await providerUserAuth( + auth, + PROVIDER_ID, + providerUserId + ); + return { + ...osuUserAuth, + osuUser, + osuTokens + }; + } + } as const satisfies OAuthProvider; +}; + +type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; + +type OsuUserStatistics = { + grade_counts: { + a: number; + s: number; + sh: number; + ss: number; + ssh: number; + }; + hit_accuracy: number; + is_ranked: boolean; + level: { + current: number; + progress: number; + }; + maximum_combo: number; + play_count: number; + play_time: number; + pp: number; + global_rank: number; + ranked_score: number; + replays_watched_by_others: number; + total_hits: number; + total_score: number; + country_rank: number; +}; + +export type OsuUser = { + avatar_url: string; + country_code: string; + default_group: string; + id: number; + is_active: boolean; + is_bot: boolean; + is_deleted: boolean; + is_online: boolean; + is_supporter: boolean; + last_visit: string; + pm_friends_only: boolean; + profile_colour: string | null; + username: string; + country: { + code: string; + name: string; + }; + cover: { + custom_url: string | null; + url: string; + id: string | null; + }; + discord: string | null; + has_supported: boolean; + interests: string | null; + join_date: string; + kudosu: { + available: number; + total: number; + }; + location: string | null; + max_blocks: number; + max_friends: number; + occupation: string | null; + playmode: OsuGameMode; + playstyle: ("mouse" | "keyboard" | "tablet" | "touch")[]; + post_count: number; + profile_order: ( + | "me" + | "recent_activity" + | "beatmaps" + | "historical" + | "kudosu" + | "top_ranks" + | "medals" + )[]; + title: string | null; + title_url: string | null; + twitter: string | null; + website: string | null; + is_restricted: boolean; + account_history: { + description: string | null; + id: number; + length: number; + permanent: boolean; + timestamp: string; + type: "note" | "restriction" | "silence"; + }[]; + active_tournament_banner: { + id: number; + tournament_id: number; + image: string; + } | null; + badges: { + awarded_at: string; + description: string; + image_url: string; + url: string; + }[]; + beatmap_playcounts_count: number; + favourite_beatmapset_count: number; + follower_count: number; + graveyard_beatmapset_count: number; + groups: { + colour: string | null; + has_listing: boolean; + has_playmodes: boolean; + id: number; + identifier: string; + is_probationary: boolean; + name: string; + short_name: string; + playmodes: OsuGameMode[] | null; + }[]; + loved_beatmapset_count: number; + mapping_follower_count: number; + monthly_playcounts: { + start_date: string; + count: number; + }[]; + page: { + html: string; + raw: string; + }; + pending_beatmapset_count: number; + previous_usernames: string[]; + rank_highest: { + rank: number; + updated_at: string; + } | null; + rank_history: { + mode: OsuGameMode; + data: number[]; + }; + ranked_beatmapset_count: number; + replays_watched_counts: { + start_date: string; + count: number; + }[]; + scores_best_count: number; + scores_first_count: number; + scores_recent_count: number; + statistics: OsuUserStatistics; + statistics_rulesets: Record; + support_level: number; + user_achievements: { + achieved_at: string; + achievement_id: number; + }[]; +};