mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-03 18:01:39 +00:00
@@ -0,0 +1,76 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /.well-known/openid-configuration
|
||||
*
|
||||
* OpenID Connect Discovery endpoint that returns the OIDC provider configuration.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/discovery.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/discovery`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
// Forward relevant headers
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OIDC Discovery error:', response.status, response.statusText);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'discovery_error',
|
||||
error_description: 'Failed to retrieve OIDC discovery configuration',
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryConfig = await response.json();
|
||||
|
||||
// Return the discovery configuration with CORS headers
|
||||
return NextResponse.json(discoveryConfig, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Discovery proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during discovery configuration retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /.well-known/openid-configuration
|
||||
*
|
||||
* Handle preflight CORS requests for the discovery endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GET } from '../.well-known/openid-configuration/route';
|
||||
|
||||
describe('OIDC Discovery Endpoint', () => {
|
||||
it('returns a valid OpenID Connect configuration', async () => {
|
||||
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const config = await response.json();
|
||||
|
||||
// Basic structure validation
|
||||
expect(config).toHaveProperty('issuer');
|
||||
expect(config).toHaveProperty('authorization_endpoint');
|
||||
expect(config).toHaveProperty('token_endpoint');
|
||||
expect(config).toHaveProperty('userinfo_endpoint');
|
||||
expect(config).toHaveProperty('jwks_uri');
|
||||
|
||||
// Validate specific SIOP requirements
|
||||
expect(config.subject_syntax_types_supported).toContain('did');
|
||||
expect(config.id_token_types_supported).toContain('subject-signed_id_token');
|
||||
});
|
||||
|
||||
it('responds with correct CORS headers', async () => {
|
||||
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
|
||||
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET');
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { POST } from '../token/route';
|
||||
|
||||
describe('OIDC Token Endpoint', () => {
|
||||
it('handles authorization code token exchange', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
client_id: 'test_client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
code_verifier: 'test_verifier',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const tokens = await response.json();
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens).toHaveProperty('token_type', 'Bearer');
|
||||
expect(tokens).toHaveProperty('expires_in');
|
||||
});
|
||||
|
||||
it('handles refresh token grant', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token',
|
||||
client_id: 'test_client',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const tokens = await response.json();
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens).toHaveProperty('token_type', 'Bearer');
|
||||
});
|
||||
|
||||
it('rejects invalid grant types', async () => {
|
||||
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'invalid_grant',
|
||||
client_id: 'test_client',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const error = await response.json();
|
||||
expect(error).toHaveProperty('error', 'unsupported_grant_type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCAuthorizationParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/authorize
|
||||
*
|
||||
* OIDC Authorization endpoint that initiates the authorization code flow.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/authorize.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Extract and validate required OIDC parameters
|
||||
const authParams: OIDCAuthorizationParams = {
|
||||
response_type: searchParams.get('response_type') || '',
|
||||
client_id: searchParams.get('client_id') || '',
|
||||
redirect_uri: searchParams.get('redirect_uri') || '',
|
||||
scope: searchParams.get('scope') || '',
|
||||
state: searchParams.get('state') || undefined,
|
||||
nonce: searchParams.get('nonce') || undefined,
|
||||
code_challenge: searchParams.get('code_challenge') || undefined,
|
||||
code_challenge_method: searchParams.get('code_challenge_method') || undefined,
|
||||
};
|
||||
|
||||
// Validate required parameters
|
||||
if (
|
||||
!authParams.response_type ||
|
||||
!authParams.client_id ||
|
||||
!authParams.redirect_uri ||
|
||||
!authParams.scope
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build query string for the bridge handler
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
queryParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/authorize?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward authentication and session headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
Referer: request.headers.get('Referer') || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle different response types from the bridge
|
||||
if (response.status === 302) {
|
||||
// Bridge handler returned a redirect - follow it
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'authorization_error',
|
||||
error_description: 'Authorization request failed',
|
||||
}));
|
||||
|
||||
console.error('OIDC Authorization error:', response.status, errorData);
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Return the authorization response with appropriate headers
|
||||
return NextResponse.json(responseData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Authorization proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during authorization',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/authorize
|
||||
*
|
||||
* Handle authorization requests sent via POST (form-encoded).
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract parameters from form data
|
||||
const authParams: OIDCAuthorizationParams = {
|
||||
response_type: formData.get('response_type')?.toString() || '',
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
redirect_uri: formData.get('redirect_uri')?.toString() || '',
|
||||
scope: formData.get('scope')?.toString() || '',
|
||||
state: formData.get('state')?.toString() || undefined,
|
||||
nonce: formData.get('nonce')?.toString() || undefined,
|
||||
code_challenge: formData.get('code_challenge')?.toString() || undefined,
|
||||
code_challenge_method: formData.get('code_challenge_method')?.toString() || undefined,
|
||||
};
|
||||
|
||||
// Validate required parameters
|
||||
if (
|
||||
!authParams.response_type ||
|
||||
!authParams.client_id ||
|
||||
!authParams.redirect_uri ||
|
||||
!authParams.scope
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Forward as form data to the bridge handler
|
||||
const bridgeFormData = new FormData();
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
bridgeFormData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/authorize`, {
|
||||
method: 'POST',
|
||||
body: bridgeFormData,
|
||||
headers: {
|
||||
// Forward authentication and session headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
Referer: request.headers.get('Referer') || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle redirect response
|
||||
if (response.status === 302) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'authorization_error',
|
||||
error_description: 'Authorization request failed',
|
||||
}));
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return NextResponse.json(responseData);
|
||||
} catch (error) {
|
||||
console.error('OIDC Authorization POST proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during authorization',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/authorize
|
||||
*
|
||||
* Handle preflight CORS requests for the authorization endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface JWK {
|
||||
kty: string;
|
||||
use?: string;
|
||||
kid: string;
|
||||
alg?: string;
|
||||
n?: string; // RSA modulus
|
||||
e?: string; // RSA exponent
|
||||
x?: string; // EC x coordinate
|
||||
y?: string; // EC y coordinate
|
||||
crv?: string; // EC curve
|
||||
}
|
||||
|
||||
interface JWKSet {
|
||||
keys: JWK[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/jwks
|
||||
*
|
||||
* OIDC JSON Web Key Set (JWKS) endpoint that returns public keys used for token verification.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/jwks.
|
||||
* The returned keys are used by relying parties to verify JWT tokens issued by this OIDC provider.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/jwks`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('OIDC JWKS error:', response.status, response.statusText);
|
||||
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to retrieve JSON Web Key Set',
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const jwks: JWKSet = await response.json();
|
||||
|
||||
// Validate JWKS structure
|
||||
if (!jwks || !Array.isArray(jwks.keys)) {
|
||||
console.error('Invalid JWKS response structure:', jwks);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Invalid JWKS response from server',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each key in the set
|
||||
const validKeys = jwks.keys.filter((key: JWK) => {
|
||||
// Basic validation for required JWK fields
|
||||
if (!key.kty || !key.kid) {
|
||||
console.warn('Invalid JWK missing required fields:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate key type specific fields
|
||||
if (key.kty === 'RSA' && (!key.n || !key.e)) {
|
||||
console.warn('Invalid RSA JWK missing n or e:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.kty === 'EC' && (!key.x || !key.y || !key.crv)) {
|
||||
console.warn('Invalid EC JWK missing x, y, or crv:', key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validatedJWKS: JWKSet = {
|
||||
keys: validKeys,
|
||||
};
|
||||
|
||||
// Return the JWKS with appropriate headers
|
||||
return NextResponse.json(validatedJWKS, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// JWKS can be cached longer since keys don't change frequently
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=43200',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
// Security headers for key endpoint
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC JWKS proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during JWKS retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/jwks
|
||||
*
|
||||
* Handle preflight CORS requests for the JWKS endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/jwks
|
||||
*
|
||||
* Return method not allowed for POST requests to JWKS endpoint.
|
||||
*/
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'POST method not allowed for JWKS endpoint. Use GET.',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'GET, OPTIONS',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCTokenRequest {
|
||||
grant_type: string;
|
||||
code?: string;
|
||||
redirect_uri?: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
code_verifier?: string;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
interface OIDCTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/token
|
||||
*
|
||||
* OIDC Token endpoint that exchanges authorization codes for access tokens.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/token.
|
||||
* Supports authorization_code, refresh_token, and client_credentials grant types.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
let tokenRequest: OIDCTokenRequest;
|
||||
|
||||
// Parse request based on content type
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
tokenRequest = {
|
||||
grant_type: formData.get('grant_type')?.toString() || '',
|
||||
code: formData.get('code')?.toString() || undefined,
|
||||
redirect_uri: formData.get('redirect_uri')?.toString() || undefined,
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
client_secret: formData.get('client_secret')?.toString() || undefined,
|
||||
code_verifier: formData.get('code_verifier')?.toString() || undefined,
|
||||
refresh_token: formData.get('refresh_token')?.toString() || undefined,
|
||||
};
|
||||
} else if (contentType.includes('application/json')) {
|
||||
tokenRequest = await request.json();
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Content-Type must be application/x-www-form-urlencoded or application/json',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing required parameters: grant_type and client_id',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate grant type specific parameters
|
||||
if (tokenRequest.grant_type === 'authorization_code') {
|
||||
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters for authorization_code grant: code and redirect_uri',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (tokenRequest.grant_type === 'refresh_token') {
|
||||
if (!tokenRequest.refresh_token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing required parameter for refresh_token grant: refresh_token',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare form data for the bridge handler (OIDC typically uses form encoding)
|
||||
const bridgeFormData = new FormData();
|
||||
Object.entries(tokenRequest).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
bridgeFormData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/token`, {
|
||||
method: 'POST',
|
||||
body: bridgeFormData,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward client authentication headers
|
||||
Authorization: request.headers.get('Authorization') || '',
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'Token request failed',
|
||||
};
|
||||
}
|
||||
|
||||
console.error('OIDC Token error:', response.status, errorData);
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const tokenResponse: OIDCTokenResponse = await response.json();
|
||||
|
||||
// Return the token response with appropriate headers
|
||||
return NextResponse.json(tokenResponse, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC Token proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during token exchange',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/token
|
||||
*
|
||||
* Handle preflight CORS requests for the token endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/token
|
||||
*
|
||||
* Return method not allowed for GET requests to token endpoint.
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'GET method not allowed for token endpoint. Use POST.',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST, OPTIONS',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
interface OIDCUserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
did?: string;
|
||||
vault_id?: string;
|
||||
updated_at?: number;
|
||||
claims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oidc/userinfo
|
||||
*
|
||||
* OIDC UserInfo endpoint that returns user information for a valid access token.
|
||||
* This endpoint proxies the request to the Go bridge handler at /oidc/userinfo.
|
||||
* Requires a valid Bearer token in the Authorization header.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
// Extract access token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_token',
|
||||
error_description:
|
||||
'Missing or invalid Authorization header. Expected format: Bearer <access_token>',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Forward the Authorization header with the access token
|
||||
Authorization: authHeader,
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
// Handle different error statuses
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
errorData = {
|
||||
error: 'invalid_token',
|
||||
error_description: 'Access token is invalid or expired',
|
||||
};
|
||||
break;
|
||||
case 403:
|
||||
errorData = {
|
||||
error: 'insufficient_scope',
|
||||
error_description: 'Access token does not have sufficient scope',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'UserInfo request failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.error('OIDC UserInfo error:', response.status, errorData);
|
||||
|
||||
const responseHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
// Add WWW-Authenticate header for 401 responses
|
||||
if (response.status === 401) {
|
||||
responseHeaders['WWW-Authenticate'] = 'Bearer realm="OIDC UserInfo", error="invalid_token"';
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const userInfo: OIDCUserInfo = await response.json();
|
||||
|
||||
// Return the user information with appropriate headers
|
||||
return NextResponse.json(userInfo, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC UserInfo proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during userinfo retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/oidc/userinfo
|
||||
*
|
||||
* OIDC UserInfo endpoint that accepts POST requests with access token in form data.
|
||||
* This is an alternative method for clients that prefer POST over GET.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
let accessToken: string | null = null;
|
||||
|
||||
// Extract access token from different sources
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.substring(7);
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
accessToken = formData.get('access_token')?.toString() || null;
|
||||
} else if (contentType.includes('application/json')) {
|
||||
const body = await request.json();
|
||||
accessToken = body.access_token || null;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'invalid_token',
|
||||
error_description: 'Access token required in Authorization header or request body',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the request to the Go bridge handler
|
||||
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
|
||||
method: 'GET', // Bridge handler expects GET
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': request.headers.get('User-Agent') || '',
|
||||
Origin: request.headers.get('Origin') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = {
|
||||
error: 'server_error',
|
||||
error_description: 'UserInfo request failed',
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(errorData, { status: response.status });
|
||||
}
|
||||
|
||||
const userInfo: OIDCUserInfo = await response.json();
|
||||
return NextResponse.json(userInfo, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OIDC UserInfo POST proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error during userinfo retrieval',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/oidc/userinfo
|
||||
*
|
||||
* Handle preflight CORS requests for the userinfo endpoint.
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface OAuthGrant {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientLogo?: string;
|
||||
scopes: string[];
|
||||
capabilities: UCANCapability[];
|
||||
grantedAt: string;
|
||||
lastUsed: string;
|
||||
expiresAt?: string;
|
||||
status: 'active' | 'expired' | 'revoked';
|
||||
}
|
||||
|
||||
interface UCANCapability {
|
||||
action: string;
|
||||
resource: string;
|
||||
caveats?: Record<string, any>;
|
||||
}
|
||||
|
||||
export default function CapabilitiesPage() {
|
||||
const [grants, setGrants] = useState<OAuthGrant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGrant, setSelectedGrant] = useState<OAuthGrant | null>(null);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGrants();
|
||||
}, []);
|
||||
|
||||
const fetchGrants = async () => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
const mockGrants: OAuthGrant[] = [
|
||||
{
|
||||
id: 'grant_1',
|
||||
clientId: 'example-app',
|
||||
clientName: 'Example Application',
|
||||
scopes: ['vault:read', 'vault:write', 'profile'],
|
||||
capabilities: [
|
||||
{ action: 'read', resource: 'vault:*' },
|
||||
{ action: 'write', resource: 'vault:*' },
|
||||
{ action: 'read', resource: 'profile:*' },
|
||||
],
|
||||
grantedAt: '2024-01-15T10:00:00Z',
|
||||
lastUsed: '2024-01-20T15:30:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'grant_2',
|
||||
clientId: 'defi-wallet',
|
||||
clientName: 'DeFi Wallet',
|
||||
scopes: ['dwn:read', 'svc:register'],
|
||||
capabilities: [
|
||||
{ action: 'read', resource: 'dwn:*' },
|
||||
{ action: 'register', resource: 'svc:*' },
|
||||
],
|
||||
grantedAt: '2024-01-10T08:00:00Z',
|
||||
lastUsed: '2024-01-18T12:00:00Z',
|
||||
expiresAt: '2024-02-10T08:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
setGrants(mockGrants);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch grants:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeGrant = async (grantId: string) => {
|
||||
setRevoking(grantId);
|
||||
try {
|
||||
// TODO: Implement actual revocation API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
setGrants((prev) =>
|
||||
prev.map((grant) =>
|
||||
grant.id === grantId ? { ...grant, status: 'revoked' as const } : grant
|
||||
)
|
||||
);
|
||||
|
||||
if (selectedGrant?.id === grantId) {
|
||||
setSelectedGrant(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke grant:', error);
|
||||
} finally {
|
||||
setRevoking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OAuthGrant['status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'expired':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'revoked':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-1" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">OAuth Capabilities</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage applications and services that have access to your account
|
||||
</p>
|
||||
</div>
|
||||
<ShieldCheckIcon className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : grants.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<ShieldCheckIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Active Grants</h3>
|
||||
<p className="text-gray-600">
|
||||
You haven't granted any applications access to your account yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Grants List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{grants.map((grant) => (
|
||||
<div
|
||||
key={grant.id}
|
||||
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-lg ${
|
||||
selectedGrant?.id === grant.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedGrant(grant)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
{grant.clientLogo ? (
|
||||
<img
|
||||
src={grant.clientLogo}
|
||||
alt={grant.clientName}
|
||||
className="w-10 h-10 rounded-lg mr-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-lg mr-3 flex items-center justify-center">
|
||||
<span className="text-gray-600 font-semibold">
|
||||
{grant.clientName[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{grant.clientName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">Client ID: {grant.clientId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{grant.scopes.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p>Granted: {formatDate(grant.grantedAt)}</p>
|
||||
<p>Last used: {formatDate(grant.lastUsed)}</p>
|
||||
{grant.expiresAt && <p>Expires: {formatDate(grant.expiresAt)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end ml-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(grant.status)}`}
|
||||
>
|
||||
{grant.status}
|
||||
</span>
|
||||
|
||||
{grant.status === 'active' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
revokeGrant(grant.id);
|
||||
}}
|
||||
disabled={revoking === grant.id}
|
||||
className="mt-4 inline-flex items-center px-3 py-1 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
>
|
||||
{revoking === grant.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-700" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="w-4 h-4 mr-1" />
|
||||
Revoke
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grant Details */}
|
||||
{selectedGrant && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Capability Details</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">UCAN Capabilities</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedGrant.capabilities.map((cap, index) => (
|
||||
<div key={index} className="p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{cap.action}</span>
|
||||
<span className="text-gray-600">{cap.resource}</span>
|
||||
</div>
|
||||
{cap.caveats && Object.keys(cap.caveats).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Caveats: {JSON.stringify(cap.caveats)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Delegation Chain</h4>
|
||||
<button className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
|
||||
View full delegation chain
|
||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Audit Log</h4>
|
||||
<p className="text-sm text-gray-600">View all activities for this grant</p>
|
||||
<button className="mt-2 inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
|
||||
View audit log
|
||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { Button, ErrorAlert } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, isAuthenticated, isLoading, error, logout } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null; // Will redirect to login
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Highway Dashboard</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
Welcome, {user.displayName || user.username}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{error && (
|
||||
<div className="mb-6">
|
||||
<ErrorAlert message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Account Information</h2>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Your account details and authentication status
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{user.username}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Display Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{user.displayName || 'Not set'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Account Created</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Authentication Method</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
WebAuthn Passkey
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Security Features
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Passwordless Authentication</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Biometric Verification</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Phishing Resistant</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label="Check mark"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Hardware Security Key Support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional app-specific styles */
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Highway - Sonr Authentication Gateway',
|
||||
description:
|
||||
'Passwordless authentication using WebAuthn passkeys for the Sonr blockchain ecosystem',
|
||||
keywords: ['webauthn', 'passkey', 'authentication', 'sonr', 'blockchain'],
|
||||
authors: [{ name: 'Sonr' }],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useWebAuthn } from '@/hooks/useWebAuthn';
|
||||
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null);
|
||||
const { authenticateUser, isLoading, error, clearError } = useWebAuthn();
|
||||
const router = useRouter();
|
||||
|
||||
// Check WebAuthn support on component mount
|
||||
useState(() => {
|
||||
const checkSupport = async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const supported =
|
||||
window.PublicKeyCredential &&
|
||||
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
|
||||
'function';
|
||||
setIsSupported(supported);
|
||||
}
|
||||
};
|
||||
checkSupport();
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
if (!username.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await authenticateUser(username.trim());
|
||||
if (success) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isSupported === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
WebAuthn Not Supported
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
|
||||
Firefox, Safari, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Authentication Successful Icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Use your passkey to securely access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && <ErrorAlert message={error} onDismiss={clearError} />}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoComplete="username"
|
||||
helpText="Enter the username you registered with"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={!username.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Signing In...' : 'Sign In with Passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Create one here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">Secure & Simple</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600 space-y-2">
|
||||
<p>• No passwords to type or remember</p>
|
||||
<p>• Authenticate with your device's biometrics</p>
|
||||
<p>• Protected against phishing and breaches</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
|
||||
import { AlertCircle, Check, Shield } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface AuthorizePageProps {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}
|
||||
|
||||
function AuthorizeContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const responseType = searchParams.get('response_type');
|
||||
const scope = searchParams.get('scope');
|
||||
const state = searchParams.get('state');
|
||||
const codeChallenge = searchParams.get('code_challenge');
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method');
|
||||
|
||||
// Get client information (in production, fetch from server)
|
||||
const clientInfo = React.useMemo(() => {
|
||||
// Mock client data - replace with actual client registry lookup
|
||||
const clients: Record<string, { name: string; logo?: string; trusted: boolean }> = {
|
||||
dev_client_123: { name: 'Development Client', trusted: true },
|
||||
example_app: { name: 'Example Application', trusted: false },
|
||||
};
|
||||
|
||||
return clients[clientId || ''] || { name: 'Unknown Application', trusted: false };
|
||||
}, [clientId]);
|
||||
|
||||
// Check if user is authenticated
|
||||
React.useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// Check for existing session
|
||||
const token = localStorage.getItem('sonr_auth_token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Validate request parameters
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !responseType) {
|
||||
setError('Missing required OAuth parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseType !== 'code' && responseType !== 'token') {
|
||||
setError('Invalid response type. Only "code" and "token" are supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate PKCE for public clients
|
||||
if (!codeChallenge && responseType === 'code') {
|
||||
setError('PKCE code challenge is required for public clients');
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||
setError('Only S256 code challenge method is supported');
|
||||
return;
|
||||
}
|
||||
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
|
||||
|
||||
// Handle authorization approval
|
||||
const handleApprove = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login with return URL
|
||||
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
const response = await fetch('/api/oauth/authorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
approved: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Authorization failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect to client with authorization code
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
if (responseType === 'code') {
|
||||
redirectUrl.searchParams.set('code', result.code);
|
||||
} else {
|
||||
// Implicit flow - add token to fragment
|
||||
const fragment = new URLSearchParams({
|
||||
access_token: result.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: result.expires_in.toString(),
|
||||
scope: scope || '',
|
||||
});
|
||||
if (state) fragment.set('state', state);
|
||||
redirectUrl.hash = fragment.toString();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl.toString();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authorization failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
searchParams,
|
||||
router,
|
||||
]);
|
||||
|
||||
// Handle denial
|
||||
const handleDeny = React.useCallback(() => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied authorization');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
}, [redirectUri, state]);
|
||||
|
||||
// Parse requested scopes
|
||||
const requestedScopes = React.useMemo(() => {
|
||||
if (!scope) return [];
|
||||
|
||||
const scopeDescriptions: Record<
|
||||
string,
|
||||
{ title: string; description: string; icon: React.ReactNode }
|
||||
> = {
|
||||
openid: {
|
||||
title: 'Basic Profile',
|
||||
description: 'Your Sonr ID and basic profile information',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Information',
|
||||
description: 'Your name, picture, and other profile details',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:read': {
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read access to your encrypted vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:write': {
|
||||
title: 'Write Vault Data',
|
||||
description: 'Create and modify data in your vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:sign': {
|
||||
title: 'Sign with Vault Keys',
|
||||
description: 'Sign transactions and messages with your vault keys',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'service:manage': {
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage services on your behalf',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
return scope.split(' ').map((s) => ({
|
||||
scope: s,
|
||||
...(scopeDescriptions[s] || {
|
||||
title: s,
|
||||
description: `Access to ${s}`,
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
}),
|
||||
}));
|
||||
}, [scope]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Authorization Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{clientInfo.logo && (
|
||||
<img src={clientInfo.logo} alt={clientInfo.name} className="h-10 w-10 rounded" />
|
||||
)}
|
||||
<div>
|
||||
<CardTitle>{clientInfo.name}</CardTitle>
|
||||
<CardDescription>wants to access your Sonr account</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{clientInfo.trusted && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{!isAuthenticated && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You need to sign in to continue with authorization
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">This application will be able to:</p>
|
||||
<div className="space-y-2">
|
||||
{requestedScopes.map(({ scope, title, description, icon }) => (
|
||||
<div key={scope} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="mt-0.5">{icon}</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By authorizing, you allow this application to access your information in accordance
|
||||
with its terms of service and privacy policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isLoading} className="flex-1">
|
||||
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorizePage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthorizeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
|
||||
import { AlertCircle, Check, Shield } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
interface ClientBranding {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logo?: string;
|
||||
verified: boolean;
|
||||
theme?: {
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
backgroundColor?: string;
|
||||
cardBackground?: string;
|
||||
borderRadius?: string;
|
||||
fontFamily?: string;
|
||||
};
|
||||
customCSS?: string;
|
||||
}
|
||||
|
||||
export default function ThemedAuthorizePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const responseType = searchParams.get('response_type');
|
||||
const scope = searchParams.get('scope');
|
||||
const state = searchParams.get('state');
|
||||
const codeChallenge = searchParams.get('code_challenge');
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method');
|
||||
|
||||
// Get client branding information (in production, fetch from server)
|
||||
const clientBranding = React.useMemo<ClientBranding>(() => {
|
||||
// Mock client data with branding - replace with actual client registry lookup
|
||||
const clients: Record<string, ClientBranding> = {
|
||||
branded_app: {
|
||||
id: 'branded_app',
|
||||
name: 'Branded Application',
|
||||
logo: '/logos/branded-app.svg',
|
||||
verified: true,
|
||||
theme: {
|
||||
primaryColor: '#4F46E5',
|
||||
accentColor: '#7C3AED',
|
||||
backgroundColor: '#F9FAFB',
|
||||
cardBackground: '#FFFFFF',
|
||||
borderRadius: '12px',
|
||||
fontFamily: '"Inter", system-ui, sans-serif',
|
||||
},
|
||||
customCSS: `
|
||||
.authorize-card {
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.scope-item {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
`,
|
||||
},
|
||||
minimal_app: {
|
||||
id: 'minimal_app',
|
||||
name: 'Minimal Application',
|
||||
verified: false,
|
||||
theme: {
|
||||
primaryColor: '#000000',
|
||||
accentColor: '#666666',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cardBackground: '#FAFAFA',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
default: {
|
||||
id: clientId || 'unknown',
|
||||
name: 'Unknown Application',
|
||||
verified: false,
|
||||
},
|
||||
};
|
||||
|
||||
return clients[clientId || ''] || clients.default;
|
||||
}, [clientId]);
|
||||
|
||||
// Apply custom theme
|
||||
React.useEffect(() => {
|
||||
if (clientBranding.theme) {
|
||||
const theme = clientBranding.theme;
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme.primaryColor) {
|
||||
root.style.setProperty('--brand-primary', theme.primaryColor);
|
||||
}
|
||||
if (theme.accentColor) {
|
||||
root.style.setProperty('--brand-accent', theme.accentColor);
|
||||
}
|
||||
if (theme.backgroundColor) {
|
||||
root.style.setProperty('--brand-background', theme.backgroundColor);
|
||||
}
|
||||
if (theme.cardBackground) {
|
||||
root.style.setProperty('--brand-card', theme.cardBackground);
|
||||
}
|
||||
if (theme.borderRadius) {
|
||||
root.style.setProperty('--brand-radius', theme.borderRadius);
|
||||
}
|
||||
if (theme.fontFamily) {
|
||||
root.style.setProperty('--brand-font', theme.fontFamily);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom CSS if provided
|
||||
if (clientBranding.customCSS) {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = clientBranding.customCSS;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(styleElement);
|
||||
};
|
||||
}
|
||||
}, [clientBranding]);
|
||||
|
||||
// Check if user is authenticated
|
||||
React.useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('sonr_auth_token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Validate request parameters
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !responseType) {
|
||||
setError('Missing required OAuth parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseType !== 'code' && responseType !== 'token') {
|
||||
setError('Invalid response type. Only "code" and "token" are supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!codeChallenge && responseType === 'code') {
|
||||
setError('PKCE code challenge is required for public clients');
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
||||
setError('Only S256 code challenge method is supported');
|
||||
return;
|
||||
}
|
||||
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
|
||||
|
||||
// Handle authorization approval
|
||||
const handleApprove = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!isAuthenticated) {
|
||||
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/oauth/authorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
approved: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Authorization failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
if (responseType === 'code') {
|
||||
redirectUrl.searchParams.set('code', result.code);
|
||||
} else {
|
||||
const fragment = new URLSearchParams({
|
||||
access_token: result.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: result.expires_in.toString(),
|
||||
scope: scope || '',
|
||||
});
|
||||
if (state) fragment.set('state', state);
|
||||
redirectUrl.hash = fragment.toString();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl.toString();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authorization failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
searchParams,
|
||||
router,
|
||||
]);
|
||||
|
||||
// Handle denial
|
||||
const handleDeny = React.useCallback(() => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied authorization');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
}, [redirectUri, state]);
|
||||
|
||||
// Parse requested scopes with custom icons
|
||||
const requestedScopes = React.useMemo(() => {
|
||||
if (!scope) return [];
|
||||
|
||||
const scopeDescriptions: Record<
|
||||
string,
|
||||
{ title: string; description: string; icon: React.ReactNode }
|
||||
> = {
|
||||
openid: {
|
||||
title: 'Basic Profile',
|
||||
description: 'Your Sonr ID and basic profile information',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Information',
|
||||
description: 'Your name, picture, and other profile details',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:read': {
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read access to your encrypted vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:write': {
|
||||
title: 'Write Vault Data',
|
||||
description: 'Create and modify data in your vault',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'vault:sign': {
|
||||
title: 'Sign with Vault Keys',
|
||||
description: 'Sign transactions and messages with your vault keys',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
'service:manage': {
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage services on your behalf',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
return scope.split(' ').map((s) => ({
|
||||
scope: s,
|
||||
...(scopeDescriptions[s] || {
|
||||
title: s,
|
||||
description: `Access to ${s}`,
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
}),
|
||||
}));
|
||||
}, [scope]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Authorization Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center p-4"
|
||||
style={{
|
||||
background: clientBranding.theme?.backgroundColor || 'var(--background)',
|
||||
fontFamily: clientBranding.theme?.fontFamily || 'inherit',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="authorize-card w-full max-w-md"
|
||||
style={{
|
||||
background: clientBranding.theme?.cardBackground || 'var(--card)',
|
||||
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{clientBranding.logo && (
|
||||
<img
|
||||
src={clientBranding.logo}
|
||||
alt={clientBranding.name}
|
||||
className="h-12 w-12 rounded"
|
||||
style={{
|
||||
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<CardTitle
|
||||
style={{
|
||||
color: clientBranding.theme?.primaryColor || 'inherit',
|
||||
}}
|
||||
>
|
||||
{clientBranding.name}
|
||||
</CardTitle>
|
||||
<CardDescription>wants to access your Sonr account</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{clientBranding.verified && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Check
|
||||
className="h-4 w-4"
|
||||
style={{ color: clientBranding.theme?.accentColor || 'rgb(34 197 94)' }}
|
||||
/>
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{!isAuthenticated && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You need to sign in to continue with authorization
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">This application will be able to:</p>
|
||||
<div className="space-y-2">
|
||||
{requestedScopes.map(({ scope, title, description, icon }) => (
|
||||
<div
|
||||
key={scope}
|
||||
className="scope-item flex items-start gap-3 p-3 rounded-lg"
|
||||
style={{
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5">{icon}</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs opacity-90">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg p-3"
|
||||
style={{
|
||||
background: clientBranding.theme?.cardBackground
|
||||
? `color-mix(in srgb, ${clientBranding.theme.cardBackground} 95%, black)`
|
||||
: 'var(--muted)',
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By authorizing, you allow this application to access your information in accordance
|
||||
with its terms of service and privacy policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
style={{
|
||||
background: clientBranding.theme?.primaryColor || 'var(--primary)',
|
||||
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { useSignInWithSonr } from '@sonr.io/ui';
|
||||
import { AlertCircle, CheckCircle, Loader2, XCircle } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = React.useState<'processing' | 'success' | 'error'>('processing');
|
||||
const [message, setMessage] = React.useState<string>('');
|
||||
const [userInfo, setUserInfo] = React.useState<Record<string, unknown> | null>(null);
|
||||
|
||||
// Extract OAuth callback parameters
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
// Get stored OAuth configuration from session
|
||||
const getStoredConfig = React.useCallback(() => {
|
||||
const stored = sessionStorage.getItem('sonr_oauth_config');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Initialize OAuth client
|
||||
const oauthConfig = React.useMemo(() => {
|
||||
const stored = getStoredConfig();
|
||||
if (stored) {
|
||||
return {
|
||||
clientId: stored.clientId,
|
||||
redirectUri: stored.redirectUri || `${window.location.origin}/oauth/callback`,
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
userInfoUrl: '/oauth2/userinfo',
|
||||
scopes: stored.scopes || ['openid', 'profile'],
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback config for development
|
||||
return {
|
||||
clientId: 'dev_client_123',
|
||||
redirectUri: `${window.location.origin}/oauth/callback`,
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
userInfoUrl: '/oauth2/userinfo',
|
||||
scopes: ['openid', 'profile'],
|
||||
};
|
||||
}, [getStoredConfig]);
|
||||
|
||||
const { handleCallback } = useSignInWithSonr(oauthConfig, {
|
||||
onSuccess: (user, token) => {
|
||||
setUserInfo(user);
|
||||
setStatus('success');
|
||||
setMessage('Authentication successful! Redirecting...');
|
||||
|
||||
// Store authentication data
|
||||
localStorage.setItem('sonr_auth_user', JSON.stringify(user));
|
||||
localStorage.setItem('sonr_auth_token', JSON.stringify(token));
|
||||
|
||||
// Get return URL from session storage
|
||||
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url');
|
||||
sessionStorage.removeItem('sonr_oauth_return_url');
|
||||
sessionStorage.removeItem('sonr_oauth_config');
|
||||
|
||||
// Redirect to return URL or dashboard
|
||||
setTimeout(() => {
|
||||
if (returnUrl) {
|
||||
// If it's an external URL, use postMessage to communicate
|
||||
if (returnUrl.startsWith('http')) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: 'sonr_auth_success',
|
||||
user,
|
||||
token,
|
||||
},
|
||||
new URL(returnUrl).origin
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
router.push(returnUrl);
|
||||
}
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus('error');
|
||||
setMessage(err.message || 'Authentication failed');
|
||||
},
|
||||
});
|
||||
|
||||
// Handle OAuth callback
|
||||
React.useEffect(() => {
|
||||
const processCallback = async () => {
|
||||
// Check for OAuth errors first
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
setMessage(errorDescription || `OAuth error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
const storedState = sessionStorage.getItem('sonr_oauth_state');
|
||||
if (state && storedState && state !== storedState) {
|
||||
setStatus('error');
|
||||
setMessage('Invalid state parameter. Possible CSRF attack.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process authorization code
|
||||
if (code) {
|
||||
try {
|
||||
await handleCallback(window.location.href);
|
||||
} catch (err) {
|
||||
console.error('Callback processing error:', err);
|
||||
setStatus('error');
|
||||
setMessage(err instanceof Error ? err.message : 'Failed to process callback');
|
||||
}
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('No authorization code received');
|
||||
}
|
||||
};
|
||||
|
||||
processCallback();
|
||||
}, [code, state, error, errorDescription, handleCallback]);
|
||||
|
||||
// Handle retry
|
||||
const handleRetry = () => {
|
||||
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url') || '/dashboard';
|
||||
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
// Handle close for popup mode
|
||||
const handleClose = () => {
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: 'sonr_auth_cancelled',
|
||||
},
|
||||
'*'
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Processing Authentication
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
Authentication Successful
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
Authentication Failed
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'processing' && 'Please wait while we complete your authentication...'}
|
||||
{status === 'success' && 'You have been successfully authenticated'}
|
||||
{status === 'error' && 'There was a problem with your authentication'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status message */}
|
||||
{message && (
|
||||
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* User info display on success */}
|
||||
{status === 'success' && userInfo && (
|
||||
<div className="space-y-2 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm font-medium">Welcome back!</p>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{userInfo.name && <p>Name: {userInfo.name}</p>}
|
||||
{userInfo.email && <p>Email: {userInfo.email}</p>}
|
||||
{userInfo.did && <p className="font-mono text-xs break-all">DID: {userInfo.did}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details */}
|
||||
{status === 'error' && error && (
|
||||
<div className="space-y-1 p-3 rounded-lg bg-destructive/10 text-sm">
|
||||
<p className="font-medium">Error Code: {error}</p>
|
||||
{errorDescription && <p className="text-muted-foreground">{errorDescription}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{status === 'processing' && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{status === 'error' && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRetry} className="flex-1">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success redirect notice */}
|
||||
{status === 'success' && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>Redirecting you to the application...</p>
|
||||
<div className="mt-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
|
||||
import { Button } from '@sonr.io/ui';
|
||||
import { Alert, AlertDescription } from '@sonr.io/ui';
|
||||
import { Checkbox } from '@sonr.io/ui';
|
||||
import { Label } from '@sonr.io/ui';
|
||||
import { AlertCircle, Database, Info, Key, Shield, Wrench } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
interface ConsentScope {
|
||||
scope: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
required: boolean;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
function ConsentContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
|
||||
const [rememberChoice, setRememberChoice] = React.useState(false);
|
||||
|
||||
// Extract OAuth parameters
|
||||
const clientId = searchParams.get('client_id');
|
||||
const redirectUri = searchParams.get('redirect_uri');
|
||||
const requestedScopes = searchParams.get('scope')?.split(' ') || [];
|
||||
const state = searchParams.get('state');
|
||||
const authCode = searchParams.get('auth_code'); // Internal auth code for consent
|
||||
|
||||
// Define available scopes with UCAN capability mappings
|
||||
const scopeDefinitions: ConsentScope[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
scope: 'openid',
|
||||
title: 'Basic Identity',
|
||||
description: 'Access to your Sonr DID and basic authentication info',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
required: true,
|
||||
capabilities: ['did:read'],
|
||||
},
|
||||
{
|
||||
scope: 'profile',
|
||||
title: 'Profile Information',
|
||||
description: 'Read your name, picture, and public profile details',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['profile:read'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:read',
|
||||
title: 'Read Vault Data',
|
||||
description: 'Read encrypted data stored in your personal vault',
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:read', 'vault:list'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:write',
|
||||
title: 'Modify Vault Data',
|
||||
description: 'Create, update, and organize data in your vault',
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:write', 'vault:create', 'vault:update'],
|
||||
},
|
||||
{
|
||||
scope: 'vault:sign',
|
||||
title: 'Sign Transactions',
|
||||
description: 'Sign messages and transactions using your vault keys',
|
||||
icon: <Key className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['vault:sign', 'tx:sign'],
|
||||
},
|
||||
{
|
||||
scope: 'service:manage',
|
||||
title: 'Manage Services',
|
||||
description: 'Register and manage decentralized services on your behalf',
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
required: false,
|
||||
capabilities: ['service:create', 'service:update', 'service:delete'],
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Filter to only requested scopes
|
||||
const availableScopes = React.useMemo(() => {
|
||||
return scopeDefinitions.filter((def) => requestedScopes.includes(def.scope));
|
||||
}, [scopeDefinitions, requestedScopes]);
|
||||
|
||||
// Initialize selected scopes with required ones
|
||||
React.useEffect(() => {
|
||||
const required = new Set(availableScopes.filter((s) => s.required).map((s) => s.scope));
|
||||
setSelectedScopes(required);
|
||||
}, [availableScopes]);
|
||||
|
||||
// Validate request
|
||||
React.useEffect(() => {
|
||||
if (!clientId || !redirectUri || !authCode) {
|
||||
setError('Invalid consent request. Missing required parameters.');
|
||||
}
|
||||
}, [clientId, redirectUri, authCode]);
|
||||
|
||||
// Handle scope toggle
|
||||
const toggleScope = (scope: string, required: boolean) => {
|
||||
if (required) return; // Can't toggle required scopes
|
||||
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(scope)) {
|
||||
next.delete(scope);
|
||||
} else {
|
||||
next.add(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle consent approval
|
||||
const handleApprove = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Submit consent decision
|
||||
const response = await fetch('/api/oauth/consent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
auth_code: authCode,
|
||||
client_id: clientId,
|
||||
approved_scopes: Array.from(selectedScopes),
|
||||
remember: rememberChoice,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error_description || 'Consent submission failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect back to authorization endpoint with consent token
|
||||
const authUrl = new URL('/oauth/authorize', window.location.origin);
|
||||
authUrl.searchParams.set('client_id', clientId!);
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri!);
|
||||
authUrl.searchParams.set('scope', Array.from(selectedScopes).join(' '));
|
||||
authUrl.searchParams.set('consent_token', result.consent_token);
|
||||
if (state) {
|
||||
authUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
router.push(authUrl.pathname + authUrl.search);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Consent submission failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle consent denial
|
||||
const handleDeny = () => {
|
||||
const redirectUrl = new URL(redirectUri!);
|
||||
redirectUrl.searchParams.set('error', 'consent_required');
|
||||
redirectUrl.searchParams.set('error_description', 'User denied consent for requested scopes');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
};
|
||||
|
||||
// Calculate total capabilities being granted
|
||||
const totalCapabilities = React.useMemo(() => {
|
||||
const caps = new Set<string>();
|
||||
availableScopes.forEach((scope) => {
|
||||
if (selectedScopes.has(scope.scope) && scope.capabilities) {
|
||||
scope.capabilities.forEach((cap) => caps.add(cap));
|
||||
}
|
||||
});
|
||||
return Array.from(caps);
|
||||
}, [availableScopes, selectedScopes]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
Consent Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Review Permissions</CardTitle>
|
||||
<CardDescription>Choose which permissions to grant this application</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Scope selection */}
|
||||
<div className="space-y-3">
|
||||
{availableScopes.map((scope) => (
|
||||
<div
|
||||
key={scope.scope}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
selectedScopes.has(scope.scope)
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: 'bg-muted/30 border-muted-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={scope.scope}
|
||||
checked={selectedScopes.has(scope.scope)}
|
||||
disabled={scope.required}
|
||||
onCheckedChange={() => toggleScope(scope.scope, scope.required)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={scope.scope} className="flex items-center gap-2 cursor-pointer">
|
||||
{scope.icon}
|
||||
<span className="font-medium">{scope.title}</span>
|
||||
{scope.required && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{scope.description}</p>
|
||||
{scope.capabilities && scope.capabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{scope.capabilities.map((cap) => (
|
||||
<span key={cap} className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* UCAN capabilities summary */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>UCAN Capabilities:</strong> This will create a delegation chain granting{' '}
|
||||
{totalCapabilities.length} capabilities to the application.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Remember choice option */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="remember" className="text-sm cursor-pointer">
|
||||
Remember my choice for this application
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Privacy notice */}
|
||||
<div className="text-xs text-muted-foreground p-3 rounded-lg bg-muted/30">
|
||||
Your data remains encrypted in your vault. Applications can only access what you
|
||||
explicitly permit through these capabilities.
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading || selectedScopes.size === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading
|
||||
? 'Processing...'
|
||||
: `Grant ${selectedScopes.size} Permission${selectedScopes.size !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsentPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ConsentContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { Button, SignInWithSonr } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, isLoading } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-gray-50">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Logo and Title */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-6">
|
||||
<svg
|
||||
className="h-8 w-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Security shield"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">Highway</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">Sonr Authentication Gateway</p>
|
||||
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
|
||||
Experience passwordless authentication with WebAuthn passkeys. Secure, simple, and
|
||||
seamless access to your digital identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
||||
<Button size="lg" onClick={() => router.push('/register')} className="px-8 py-3">
|
||||
Create Account
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.push('/login')}
|
||||
className="px-8 py-3"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OAuth Provider Section */}
|
||||
<div className="border-t pt-8 mb-8">
|
||||
<p className="text-sm text-gray-600 mb-4">Or use Sonr as an OAuth provider</p>
|
||||
<SignInWithSonr
|
||||
clientId="demo_client"
|
||||
redirectUri={`${window.location.origin}/oauth/callback`}
|
||||
scopes={['openid', 'profile', 'vault:read']}
|
||||
onSuccess={(result) => {
|
||||
console.log('OAuth success:', result);
|
||||
router.push('/dashboard');
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('OAuth error:', error);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Lock icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Passwords</h3>
|
||||
<p className="text-gray-600">
|
||||
Authenticate using your device's built-in security features like fingerprint or face
|
||||
recognition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Lightning bolt"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Lightning Fast</h3>
|
||||
<p className="text-gray-600">
|
||||
Sign in instantly without typing passwords. One touch or glance is all it takes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-purple-100 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Shield check"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Secure by Design</h3>
|
||||
<p className="text-gray-600">
|
||||
Protected against phishing, breaches, and replay attacks with cryptographic
|
||||
security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>Powered by WebAuthn and FIDO2 standards</p>
|
||||
<p className="mt-1">Compatible with modern browsers and platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useWebAuthn } from '@/hooks/useWebAuthn';
|
||||
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null);
|
||||
const { registerUser, isLoading, error, clearError } = useWebAuthn();
|
||||
const router = useRouter();
|
||||
|
||||
// Check WebAuthn support on component mount
|
||||
useState(() => {
|
||||
const checkSupport = async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const supported =
|
||||
window.PublicKeyCredential &&
|
||||
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
|
||||
'function';
|
||||
setIsSupported(supported);
|
||||
}
|
||||
};
|
||||
checkSupport();
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
if (!username.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await registerUser(username.trim(), displayName.trim() || undefined);
|
||||
if (success) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isSupported === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
WebAuthn Not Supported
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
|
||||
Firefox, Safari, or Edge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Account Creation Icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Register with a passkey for secure, passwordless authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && <ErrorAlert message={error} onDismiss={clearError} />}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoComplete="username"
|
||||
helpText="Choose a unique username for your account"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Display Name (Optional)"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
autoComplete="name"
|
||||
helpText="This will be shown in your profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={!username.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account with Passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Sign in here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">What is a passkey?</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600 space-y-2">
|
||||
<p>• No passwords to remember or type</p>
|
||||
<p>• Uses your device's built-in security (fingerprint, face, PIN)</p>
|
||||
<p>• More secure than traditional passwords</p>
|
||||
<p>• Works across all your devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user